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

@@ -190,7 +190,10 @@ export class Tooltip extends LitElement {
middleware: [ middleware: [
this.autoFlip && flip({ padding: AUTO_FLIP_PADDING }), this.autoFlip && flip({ padding: AUTO_FLIP_PADDING }),
this.autoShift && shift({ padding: AUTO_SHIFT_PADDING }), this.autoShift && shift({ padding: AUTO_SHIFT_PADDING }),
offset((this.arrow ? TRIANGLE_HEIGHT : 0) + this.offset), offset({
mainAxis: (this.arrow ? TRIANGLE_HEIGHT : 0) + this.offsetY,
crossAxis: this.offsetX,
}),
arrow({ arrow({
element: portalRoot.shadowRoot!.querySelector('.arrow')!, element: portalRoot.shadowRoot!.querySelector('.arrow')!,
}), }),
@@ -264,7 +267,7 @@ export class Tooltip extends LitElement {
* Show a triangle arrow pointing to the reference element. * Show a triangle arrow pointing to the reference element.
*/ */
@property({ attribute: false }) @property({ attribute: false })
accessor arrow = true; accessor arrow = false;
/** /**
* changes the placement of the floating element in order to keep it in view, * changes the placement of the floating element in order to keep it in view,
@@ -303,7 +306,10 @@ export class Tooltip extends LitElement {
* See https://floating-ui.com/docs/offset * See https://floating-ui.com/docs/offset
*/ */
@property({ attribute: false }) @property({ attribute: false })
accessor offset = 4; accessor offsetY = 6;
@property({ attribute: false })
accessor offsetX = 0;
@property({ attribute: 'tip-position' }) @property({ attribute: 'tip-position' })
accessor placement: Placement = 'top'; accessor placement: Placement = 'top';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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