feat(core): support open doc in ai session history (#13035)

Close [AI-240
<img width="533" alt="截屏2025-07-04 18 04 39"
src="https://github.com/user-attachments/assets/726a54b6-3bdb-4e70-9cda-4671d83ae5bd"
/>
](https://linear.app/affine-design/issue/AI-240)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Enhanced chat toolbar and session history with the ability to open
specific documents directly from the chat interface.
* Added tooltips and improved click handling for clearer user
interactions in chat session and document lists.

* **Bug Fixes**
* Prevented redundant actions when attempting to open already active
sessions or documents.

* **Style**
* Improved tooltip formatting and visual styling for error messages and
tooltips.
* Refined hover effects and layout in chat session history for better
clarity.

* **Refactor**
* Updated tooltip configuration for more precise positioning and
behavior.

* **Chores**
* Minor updates to property defaults for tooltips and chat panel
components.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: fengmk2 <fengmk2@gmail.com>
This commit is contained in:
Wu Yue
2025-07-04 19:00:52 +08:00
committed by GitHub
parent c882a8c5da
commit 2f9a96f1c5
8 changed files with 145 additions and 32 deletions

View File

@@ -1,5 +1,6 @@
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { WorkbenchService } from '@affine/core/modules/workbench';
import type {
ContextEmbedStatus,
CopilotSessionType,
@@ -98,6 +99,9 @@ export class ChatPanel extends SignalWatcher(
@property({ attribute: false })
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@property({ attribute: false })
accessor affineWorkbenchService!: WorkbenchService;
@state()
accessor session: CopilotSessionType | null | undefined;
@@ -137,34 +141,72 @@ export class ChatPanel extends SignalWatcher(
<ai-chat-toolbar
.session=${this.session}
.workspaceId=${this.doc.workspace.id}
.docId=${this.doc.id}
.onNewSession=${this.newSession}
.onTogglePin=${this.togglePin}
.onOpenSession=${this.openSession}
.onOpenDoc=${this.openDoc}
.docDisplayConfig=${this.docDisplayConfig}
.notification=${notification}
></ai-chat-toolbar>
`;
}
private readonly getSessionIdFromUrl = () => {
if (this.affineWorkbenchService) {
const { workbench } = this.affineWorkbenchService;
const location = workbench.location$.value;
const searchParams = new URLSearchParams(location.search);
const sessionId = searchParams.get('sessionId');
if (sessionId) {
workbench.activeView$.value.updateQueryString(
{ sessionId: undefined },
{ replace: true }
);
}
return sessionId;
}
return undefined;
};
private readonly setSession = (
session: CopilotSessionType | null | undefined
) => {
this.session = session ?? null;
};
private readonly initSession = async () => {
if (!AIProvider.session) {
return;
}
const sessionId = this.getSessionIdFromUrl();
const pinSessions = await AIProvider.session.getSessions(
this.doc.workspace.id,
undefined,
{ pinned: true, limit: 1 }
);
if (Array.isArray(pinSessions) && pinSessions[0]) {
// pinned session
this.session = pinSessions[0];
} else if (sessionId) {
// sessionId from url
const session = await AIProvider.session.getSession(
this.doc.workspace.id,
sessionId
);
this.setSession(session);
} else {
// latest doc session
const docSessions = await AIProvider.session.getSessions(
this.doc.workspace.id,
this.doc.id,
{ action: false, fork: false, limit: 1 }
);
// sessions is descending ordered by updatedAt
// the first item is the latest session
this.session = docSessions?.[0] ?? null;
const session = docSessions?.[0];
this.setSession(session);
}
};
@@ -186,7 +228,7 @@ export class ChatPanel extends SignalWatcher(
this.doc.workspace.id,
sessionId
);
this.session = session ?? null;
this.setSession(session);
}
return this.session;
};
@@ -197,7 +239,7 @@ export class ChatPanel extends SignalWatcher(
this.doc.workspace.id,
options.sessionId
);
this.session = session ?? null;
this.setSession(session);
};
private readonly newSession = () => {
@@ -208,12 +250,31 @@ export class ChatPanel extends SignalWatcher(
};
private readonly openSession = async (sessionId: string) => {
if (this.session?.id === sessionId) {
return;
}
this.resetPanel();
const session = await AIProvider.session?.getSession(
this.doc.workspace.id,
sessionId
);
this.session = session ?? null;
this.setSession(session);
};
private readonly openDoc = async (docId: string, sessionId: string) => {
if (this.doc.id === docId) {
if (this.session?.id === sessionId || this.session?.pinned) {
return;
}
await this.openSession(sessionId);
} else if (this.affineWorkbenchService) {
const { workbench } = this.affineWorkbenchService;
if (this.session?.pinned) {
workbench.open(`/${docId}`, { at: 'active' });
} else {
workbench.open(`/${docId}?sessionId=${sessionId}`, { at: 'active' });
}
}
};
private readonly togglePin = async () => {

View File

@@ -23,6 +23,9 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor workspaceId!: string;
@property({ attribute: false })
accessor docId: string | undefined;
@property({ attribute: false })
accessor onNewSession!: () => void;
@@ -32,6 +35,9 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor onOpenSession!: (sessionId: string) => void;
@property({ attribute: false })
accessor onOpenDoc!: (docId: string, sessionId: string) => void;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@@ -80,9 +86,9 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
</div>
<div class="chat-toolbar-icon" @click=${this.onTogglePin}>
${pinned ? PinedIcon() : PinIcon()}
<affine-tooltip
>${pinned ? 'Unpin this Chat' : 'Pin this Chat'}</affine-tooltip
>
<affine-tooltip>
${pinned ? 'Unpin this Chat' : 'Pin this Chat'}
</affine-tooltip>
</div>
<div
class="chat-toolbar-icon history-button"
@@ -126,12 +132,24 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
};
private readonly onSessionClick = async (sessionId: string) => {
if (this.session?.id === sessionId) {
this.notification?.toast('You are already in this chat');
return;
}
const confirm = await this.unpinConfirm();
if (confirm) {
this.onOpenSession(sessionId);
}
};
private readonly onDocClick = async (docId: string, sessionId: string) => {
if (this.docId === docId && this.session?.id === sessionId) {
this.notification?.toast('You are already in this chat');
return;
}
this.onOpenDoc(docId, sessionId);
};
private readonly toggleHistoryMenu = () => {
if (this.abortController) {
this.abortController.abort();
@@ -150,6 +168,7 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
.workspaceId=${this.workspaceId}
.docDisplayConfig=${this.docDisplayConfig}
.onSessionClick=${this.onSessionClick}
.onDocClick=${this.onDocClick}
.notification=${this.notification}
></ai-session-history>
`,

View File

@@ -45,17 +45,22 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
}
.ai-session-item {
position: relative;
display: flex;
height: 24px;
padding: 2px 4px;
justify-content: space-between;
align-items: center;
border-radius: 4px;
cursor: pointer;
}
.ai-session-item:hover {
.ai-session-item:hover:not(:has(.ai-session-doc:hover)) {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.ai-session-doc:hover {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
border-color: ${unsafeCSSVarV2('layer/insideBorder/border')};
}
.ai-session-title {
@@ -75,6 +80,7 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
align-items: center;
gap: 4px;
flex-shrink: 0;
border-radius: 2px;
cursor: pointer;
svg {
@@ -110,6 +116,9 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor onSessionClick!: (sessionId: string) => void;
@property({ attribute: false })
accessor onDocClick!: (docId: string, sessionId: string) => void;
@property({ attribute: false })
accessor notification: NotificationService | null | undefined;
@@ -194,10 +203,20 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
return html`
<div
class="ai-session-item"
@click=${() => this.onSessionClick(session.sessionId)}
@click=${(e: MouseEvent) => {
e.stopPropagation();
this.onSessionClick(session.sessionId);
}}
>
<div class="ai-session-title">${session.sessionId}</div>
${session.docId ? this.renderSessionDoc(session.docId) : nothing}
<div class="ai-session-title">
${session.sessionId}
<affine-tooltip .offsetX=${60}>
Click to open this chat
</affine-tooltip>
</div>
${session.docId
? this.renderSessionDoc(session.docId, session.sessionId)
: nothing}
</div>
`;
})}
@@ -205,12 +224,19 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
`;
}
private renderSessionDoc(docId: string) {
private renderSessionDoc(docId: string, sessionId: string) {
const getIcon = this.docDisplayConfig.getIcon(docId);
const docIcon = typeof getIcon === 'function' ? getIcon() : getIcon;
return html`<div class="ai-session-doc">
return html`<div
class="ai-session-doc"
@click=${(e: MouseEvent) => {
e.stopPropagation();
this.onDocClick(docId, sessionId);
}}
>
${docIcon}
<span class="doc-title">${this.docDisplayConfig.getTitle(docId)}</span>
<span class="doc-title"> ${this.docDisplayConfig.getTitle(docId)} </span>
<affine-tooltip>Open this doc</affine-tooltip>
</div>`;
}

View File

@@ -155,9 +155,9 @@ export class AIErrorWrapper extends SignalWatcher(WithDisposable(LitElement)) {
>
${this.actionText}
${this.actionTooltip
? html`<affine-tooltip tip-position="top"
>${this.actionTooltip}</affine-tooltip
>`
? html`<affine-tooltip tip-position="top">
${this.actionTooltip}
</affine-tooltip>`
: nothing}
</span>
</div>

View File

@@ -182,13 +182,12 @@ export class AIPanelError extends WithDisposable(LitElement) {
() => {
const tip = this.config.error?.message;
const error = tip
? html`<span class="error-tip"
>An error occurred<affine-tooltip
tip-position="bottom-start"
.arrow=${false}
>${tip}</affine-tooltip
></span
>`
? html`<span class="error-tip">
An error occurred
<affine-tooltip tip-position="bottom-start">
${tip}
</affine-tooltip>
</span>`
: 'An error occurred';
return html`
<style>

View File

@@ -251,9 +251,9 @@ export class AIPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
@pointerdown=${stopPropagation}
>
${PublishIcon()}
<affine-tooltip .offset=${12}
>Toggle Network Search</affine-tooltip
>
<affine-tooltip .offsetY=${12}>
Toggle Network Search
</affine-tooltip>
</div>
`
: nothing}
@@ -265,7 +265,7 @@ export class AIPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
>
${SendIcon()}
${this._hasContent
? html`<affine-tooltip .offset=${12}>Send to AI</affine-tooltip>`
? html`<affine-tooltip .offsetY=${12}>Send to AI</affine-tooltip>`
: nothing}
</div>
</div>

View File

@@ -84,6 +84,8 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
chatPanelRef.current.affineWorkspaceDialogService = framework.get(
WorkspaceDialogService
);
chatPanelRef.current.affineWorkbenchService =
framework.get(WorkbenchService);
containerRef.current?.append(chatPanelRef.current);
} else {