fix(editor): resolve UX inconsistencies in the AI chat interface (#14850)

# Closes #14189.

Fixes the three UX issues reported in the original bug report, plus one
small
adjacent polish on the right-sidebar toggle that was requested during
review.

Each concern in the issue is addressed end-to-end, with the same
treatment
applied to both places the AI chat panel lives: the **sidebar chat
panel**
(right panel on a doc page) and the **standalone `/chat` page**.

---

## 1. `+` button → persistent multi-session tabs (issue point 1)

**Before:** clicking `+` called `createFreshSession()` (standalone) or
`newSession()` (sidebar), both of which tore down the current chat
content
and replaced it in place. There was no way to keep two chats open at
once.

**After:** a browser/IDE-style tab strip lives above the chat content.
Each
open session gets its own tab with a close `×`; the active tab is
highlighted; `+` now adds a tab rather than replacing the chat.

### Details
- New Lit component `ai-chat-tabs`
([packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-tabs.ts](packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-tabs.ts)).
- Tab title is derived from `session.title` → first user message → `"New
chat"`.
- Horizontal scroll when tabs overflow, with a `wheel` handler that
converts
    mouse wheel / trackpad vertical swipe into horizontal scroll (native
horizontal trackpad swipes also work natively via `overflow-x: auto`).
- Auto `scrollIntoView({ inline: 'nearest' })` on active tab change, so
a
newly created or newly selected tab slides into view instead of staying
    hidden behind the toolbar.
- Close `×` removes the tab from the strip but leaves the session on the
server (matches the existing **Chat history** dropdown semantics — the
session is still reachable there). Closing the active tab switches to an
    adjacent one; closing the last tab starts a fresh session.
- Persistence: open session IDs are saved per-workspace in
`localStorage`
under `ai-chat-open-tabs:{workspaceId}`. On mount, the React pages
hydrate
  those IDs via `AIProvider.session.getSession` /
  `CopilotClient.getSession` — no new backend or schema work.
- Wiring: identical effects on both variants
([chat.tsx
(sidebar)](packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx)
and
[chat/index.tsx
(standalone)](packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx))
  — hydrate → sync active session into tabs → persist.
- The tab strip sits on the same row as the existing toolbar icons
  (pin / history / `+`), separated by `flex: 1` + `min-width: 0` so the
  tabs scroll cleanly up to the toolbar boundary.
- The `ShadowlessElement` base class injects its static CSS globally,
and the
`:host` selector does not match in a React-rooted DOM — the component
uses
  tag-selector CSS (`ai-chat-tabs { display: flex; … }`) instead.

## 2. Drag-and-drop attachments (issue point 2)

**Before:** the chat input accepted no DnD. Attaching anything required
the
`+` → file-picker flow.

**After:** the chat input accepts OS files via native HTML5 DnD and
AFFiNE
documents via the repo's existing pragmatic-drag-and-drop
infrastructure.

### Details
- Native handlers (`dragenter/over/leave/drop`) on

[ai-chat-input.ts](packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts)
accept OS files: images go into the image preview grid, other files
become
  attachment chips, with the same 50 MB per-file cap as the `+` picker.
- Internal AFFiNE document drags from the nav panel land as doc chips,
  handled via `dropTargetForElements` from
  `@atlaskit/pragmatic-drag-and-drop` (same library the rest of the app
  already uses for internal DnD).
- A "Drop to attach" overlay appears during drag, reusing the existing
focused-border token (`--affine-v2-layer-insideBorder-primaryBorder`)
for
  visual consistency with the focused state.
- The image/file routing logic that previously lived inline in
  `add-popover.ts` was factored into a shared helper

[attachment-utils.ts](packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/attachment-utils.ts)
  (`addFilesToChat`), so the `+` picker and the drop handler stay in
  lockstep.
- Analytics: extended the `addEmbeddingDoc.control` union in
[events.ts](packages/frontend/track/src/events.ts) with `'dragDrop'` so
  drag-originated attachments are distinguishable from button-initiated
  ones in telemetry.
- `@atlaskit/pragmatic-drag-and-drop` is promoted from a transitive
  dependency (via `@affine/component`) to a direct dependency of
  `@affine/core` and `yarn.lock` is refreshed accordingly.

## 3. Chat-history tooltip + icon (issue point 3)

**Before:** hovering the chat-history button showed a tooltip whose
background did not invert for dark theme (`--affine-tooltip` is not
theme-aware), and the icon was `ArrowDownSmallIcon` — a chevron that
does
not convey "history."

**After:** the tooltip primitive itself is theme-aware (every tooltip in
the app benefits, not just the chat one), and the icon is the
semantically-clear `HistoryIcon`.

### Details
- [tooltip.ts](blocksuite/affine/components/src/tooltip/tooltip.ts) now
uses
  `var(--affine-v2-tooltips-background, var(--affine-tooltip))` and
  `var(--affine-v2-tooltips-foreground, var(--affine-white))`. The V2
  tokens auto-invert with theme; the old vars remain as fallbacks so
  components that override via the existing `tooltipStyle` escape hatch
  continue to work.
- Triangle arrow colors updated to use the same V2 token.
-
[ai-chat-toolbar.ts](packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-toolbar.ts):
  `ArrowDownSmallIcon` → `HistoryIcon`; added
  `data-testid="ai-panel-chat-history"` for future e2e coverage.

## 4. Right-sidebar toggle: tooltips + open-state icon *(adjacent
polish)*

Not part of the original issue, but surfaced while testing the tab strip
—
neither of the two right-sidebar toggle buttons had hover affordance,
and
both used the same icon regardless of the sidebar's state.

- Added `tooltip="Open sidebar"` on the route-container button shown
when
  the sidebar is hidden.
- Added `tooltip="Close sidebar"` on the sidebar-header button shown
when
  the sidebar is expanded.
- The close button now renders a small inline `RightSidebarOpenIcon`
  variant: same outline as `RightSidebarIcon`, but with the right panel
  filled in the AFFiNE accent color to convey the open state. Icon shape
  change is self-contained — no new icon asset added to
  `@blocksuite/icons`.

---

## Commits

- `2adc0c7` — fix(ai-chat): theme-aware tooltip + semantic chat-history
icon *(2 files)*
- `bf26974` — feat(ai-chat): drag-and-drop file and doc attachments in
chat input *(7 files)*
- `fca29c8` — feat(ai-chat): persistent multi-session tab strip *(8
files)*
- `7d5dffe` — feat(workbench): tooltips and open-state icon for the
right-sidebar toggle *(2 files)*

Kept ordered smallest → largest blast radius so the history is easy to
bisect.

---

## Test plan

Verified locally against a fresh server stack (postgres / redis /
mailpit via
compose, migrations run) signed in as `dev@affine.pro`, in both `/chat`
and
the sidebar chat on a doc page, in light and dark themes:

- [x] Tooltip: hover the chat-history icon in dark mode → tooltip is
dark-on-light; toggle to light mode → tooltip is light-on-dark. Existing
tooltips on other surfaces (slash menu, edgeless, linked-doc) still
render correctly.
- [x] Icon: chat-history button renders the history glyph (clock), not a
chevron.
- [x] Drag-and-drop (OS file): drop a PDF / PNG / TXT onto the input →
overlay shows → chips/images appear; file > 50 MB → rejected silently
(same as `+` picker).
- [x] Drag-and-drop (internal doc): drag an AFFiNE doc from the nav
panel → becomes a doc chip.
- [x] Pin-picker, `+` picker, paste-image — all unchanged.
- [x] Tab strip: first chat auto-becomes a tab on first message; `+`
adds tab; click tab switches chat; `×` removes tab and switches to
adjacent; close last tab → new fresh tab spawns.
- [x] Reload browser → tab strip rehydrates from localStorage with the
same sessions.
- [x] Tab overflow: 12+ tabs → horizontal scroll via trackpad vertical
swipe, trackpad horizontal swipe, and mouse wheel; active tab
auto-scrolls into view on `+` click.
- [x] Right-sidebar: hover both toggle buttons → tooltips appear; open
the sidebar → close button shows the filled right-panel icon.
- [x] `yarn lint:ox` and lint-staged both clean on every commit.

Not verified locally (no local model key configured): the assistant
actually
streams a response. Drop/chip flow is independent of that path.

## Out of scope / follow-ups

- No new unit or Playwright tests — the fixes are visually verifiable
and
  reuse existing reducer / state paths. Happy to add tests if reviewers
  prefer.
- `@affine/native` is not required for the web dev stack; I only built
  `@affine/server-native`. Irrelevant to the PR diff.


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

* **New Features**
* Multi-tab chat UI with a tabs component, open/close/switch actions,
and per-workspace persistence/restoration.
  * Drag-and-drop attachments into chat input (files and docs).

* **UI/UX**
  * Tooltip theming moved to v2 variables (includes arrow color).
  * Sidebar toggle/close buttons now show tooltips.
  * “Drop to attach” overlay and updated history icon.

* **Behavior**
  * Unified attachment handling with 50MB validation and toast notices.

* **Analytics**
  * Attachment events record drag-and-drop as a control method.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
This commit is contained in:
Adarsh Singh
2026-05-07 01:34:43 +05:30
committed by GitHub
parent eb9cc22502
commit 440ff0c342
20 changed files with 781 additions and 40 deletions

View File

@@ -24,8 +24,8 @@ const styles = css`
font-size: var(--affine-font-sm);
border-radius: 4px;
padding: 6px 12px;
color: var(--affine-white);
background: var(--affine-tooltip);
color: var(--affine-v2-tooltips-foreground, var(--affine-white));
background: var(--affine-v2-tooltips-background, var(--affine-tooltip));
overflow-wrap: anywhere;
white-space: normal;
@@ -40,6 +40,9 @@ const styles = css`
}
`;
const TOOLTIP_ARROW_COLOR =
'var(--affine-v2-tooltips-background, var(--affine-tooltip))';
// See http://apps.eky.hk/css-triangle-generator/
const TRIANGLE_HEIGHT = 6;
const triangleMap = {
@@ -47,25 +50,25 @@ const triangleMap = {
bottom: '-6px',
borderStyle: 'solid',
borderWidth: '6px 5px 0 5px',
borderColor: 'var(--affine-tooltip) transparent transparent transparent',
borderColor: `${TOOLTIP_ARROW_COLOR} transparent transparent transparent`,
},
right: {
left: '-6px',
borderStyle: 'solid',
borderWidth: '5px 6px 5px 0',
borderColor: 'transparent var(--affine-tooltip) transparent transparent',
borderColor: `transparent ${TOOLTIP_ARROW_COLOR} transparent transparent`,
},
bottom: {
top: '-6px',
borderStyle: 'solid',
borderWidth: '0 5px 6px 5px',
borderColor: 'transparent transparent var(--affine-tooltip) transparent',
borderColor: `transparent transparent ${TOOLTIP_ARROW_COLOR} transparent`,
},
left: {
right: '-6px',
borderStyle: 'solid',
borderWidth: '5px 0 5px 6px',
borderColor: 'transparent transparent transparent var(--affine-tooltip)',
borderColor: `transparent transparent transparent ${TOOLTIP_ARROW_COLOR}`,
},
};

View File

@@ -19,6 +19,7 @@
"@affine/reader": "workspace:*",
"@affine/templates": "workspace:*",
"@affine/track": "workspace:*",
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@blocksuite/affine": "workspace:*",
"@blocksuite/affine-block-root": "workspace:*",
"@blocksuite/affine-components": "workspace:*",

View File

@@ -1,4 +1,3 @@
import { toast } from '@affine/component';
import type { TagMeta } from '@affine/core/components/page-list';
import type { CollectionMeta } from '@affine/core/modules/collection';
import track, { type EventArgs } from '@affine/track';
@@ -22,6 +21,7 @@ import { property, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import { addFilesToChat } from './attachment-utils';
import type { ChatChip, DocDisplayConfig } from './type';
enum AddPopoverMode {
@@ -172,23 +172,10 @@ export class ChatPanelAddPopover extends SignalWatcher(
if (!files || files.length === 0) return;
this.abortController.abort();
const images = files.filter(file => file.type.startsWith('image/'));
if (images.length > 0) {
this.addImages(images);
}
const others = files.filter(file => !file.type.startsWith('image/'));
const addChipPromises = others.map(async file => {
if (file.size > 50 * 1024 * 1024) {
toast(`${file.name} is too large, please upload a file less than 50MB`);
return;
}
await this.addChip({
file,
state: 'processing',
});
await addFilesToChat(files, {
addImages: this.addImages,
addChip: this.addChip,
});
await Promise.all(addChipPromises);
this._track('file');
};

View File

@@ -0,0 +1,36 @@
import { toast } from '@affine/component';
import type { ChatChip } from './type';
const MAX_ATTACHMENT_SIZE = 50 * 1024 * 1024;
export interface AttachmentHandlers {
addImages: (images: File[]) => void;
addChip: (chip: ChatChip, silent?: boolean) => Promise<void>;
}
export async function addFilesToChat(
files: File[],
{ addImages, addChip }: AttachmentHandlers
): Promise<void> {
if (!files.length) return;
const images = files.filter(file => file.type.startsWith('image/'));
if (images.length > 0) {
addImages(images);
}
const others = files.filter(file => !file.type.startsWith('image/'));
await Promise.all(
others.map(async file => {
if (file.size > MAX_ATTACHMENT_SIZE) {
toast(`${file.name} is too large, please upload a file less than 50MB`);
return;
}
await addChip({
file,
state: 'processing',
});
})
);
}

View File

@@ -1,2 +1,3 @@
export * from './attachment-utils';
export * from './type';
export * from './utils';

View File

@@ -9,6 +9,9 @@ import type {
} from '@affine/core/modules/cloud';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import track, { type EventArgs } from '@affine/track';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import type { EditorHost } from '@blocksuite/affine/std';
@@ -26,6 +29,7 @@ import { reportResponse } from '../../utils/action-reporter';
import { readBlobAsURL } from '../../utils/image';
import { mergeStreamObjects } from '../../utils/stream-objects';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import { addFilesToChat } from '../ai-chat-chips/attachment-utils';
import type { ChatChip, DocDisplayConfig } from '../ai-chat-chips/type';
import { isDocChip } from '../ai-chat-chips/utils';
import {
@@ -257,6 +261,31 @@ export class AIChatInput extends SignalWatcher(
user-select: none;
}
.chat-panel-input[data-drag-over='true'] {
--input-border-width: 1px;
--input-border-color: var(--affine-v2-layer-insideBorder-primaryBorder);
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.chat-panel-input-drop-overlay {
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 500;
color: ${unsafeCSSVarV2('icon/activated')};
background-color: color-mix(
in srgb,
var(--affine-v2-layer-background-primary) 92%,
transparent
);
z-index: 1;
}
.chat-panel-send {
display: flex;
justify-content: center;
@@ -326,6 +355,16 @@ export class AIChatInput extends SignalWatcher(
@state()
accessor focused = false;
@state()
accessor isDragOver = false;
@query('.chat-panel-input')
accessor chatPanelInput!: HTMLDivElement;
private _dragEnterCounter = 0;
private _internalDropCleanup: (() => void) | null = null;
@property({ attribute: false })
accessor chatContextValue!: AIChatInputContext;
@@ -434,6 +473,18 @@ export class AIChatInput extends SignalWatcher(
}
})
);
this.updateComplete
.then(() => {
if (this.isConnected && !this._internalDropCleanup) {
this._setupInternalDropTarget();
}
})
.catch(console.error);
window.addEventListener('dragleave', this._handleWindowDragLeave);
window.addEventListener('drop', this._resetDragState);
window.addEventListener('dragend', this._resetDragState);
}
protected override firstUpdated(changedProperties: PropertyValues): void {
@@ -449,6 +500,57 @@ export class AIChatInput extends SignalWatcher(
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this._internalDropCleanup?.();
this._internalDropCleanup = null;
window.removeEventListener('dragleave', this._handleWindowDragLeave);
window.removeEventListener('drop', this._resetDragState);
window.removeEventListener('dragend', this._resetDragState);
}
private _trackDragDrop(method: EventArgs['addEmbeddingDoc']['method']) {
const page = this.independentMode
? track.$.intelligence
: track.$.chatPanel;
page.chatPanelInput.addEmbeddingDoc({
control: 'dragDrop',
method,
});
}
private _setupInternalDropTarget() {
const el = this.chatPanelInput;
if (!el) return;
const dropTargetCleanup = dropTargetForElements({
element: el,
canDrop: ({ source }) => {
const entity = (source.data as { entity?: { type?: string } }).entity;
return entity?.type === 'doc';
},
onDragEnter: () => {
this.isDragOver = true;
},
onDragLeave: () => {
this.isDragOver = false;
},
onDrop: ({ source }) => {
this.isDragOver = false;
const entity = (
source.data as { entity?: { type?: string; id?: string } }
).entity;
if (entity?.type === 'doc' && entity.id) {
this.addChip({
docId: entity.id,
state: 'processing',
}).catch(console.error);
this._trackDragDrop('doc');
}
},
});
this._internalDropCleanup = combine(dropTargetCleanup);
}
protected override render() {
const { images, status } = this.chatContextValue;
const hasImages = images.length > 0;
@@ -458,11 +560,19 @@ export class AIChatInput extends SignalWatcher(
class="chat-panel-input"
data-independent-mode=${this.independentMode}
data-if-focused=${this.focused}
data-drag-over=${this.isDragOver}
style=${styleMap({
maxHeight: `${maxHeight}px !important`,
})}
@pointerdown=${this._handlePointerDown}
@dragenter=${this._handleDragEnter}
@dragover=${this._handleDragOver}
@dragleave=${this._handleDragLeave}
@drop=${this._handleDrop}
>
${this.isDragOver
? html`<div class="chat-panel-input-drop-overlay">Drop to attach</div>`
: nothing}
${hasImages
? html`
<image-preview-grid
@@ -611,6 +721,66 @@ export class AIChatInput extends SignalWatcher(
}
};
private _dragHasFiles(event: DragEvent) {
return Array.from(event.dataTransfer?.types ?? []).includes('Files');
}
private readonly _handleDragEnter = (event: DragEvent) => {
if (!this._dragHasFiles(event)) return;
event.preventDefault();
this._dragEnterCounter += 1;
this.isDragOver = true;
};
private readonly _handleDragOver = (event: DragEvent) => {
if (!this._dragHasFiles(event)) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
};
private readonly _handleDragLeave = (event: DragEvent) => {
if (!this._dragHasFiles(event)) return;
this._dragEnterCounter = Math.max(0, this._dragEnterCounter - 1);
if (this._dragEnterCounter === 0) {
this.isDragOver = false;
}
};
private readonly _resetDragState = () => {
if (this._dragEnterCounter === 0 && !this.isDragOver) return;
this._dragEnterCounter = 0;
this.isDragOver = false;
};
// Covers the cases where the drag session ends without dragleave/drop firing
// on the input (Esc-cancel, release outside window, drop on another element).
private readonly _handleWindowDragLeave = (event: DragEvent) => {
if (event.relatedTarget === null) this._resetDragState();
};
private readonly _handleDrop = async (event: DragEvent) => {
if (!this._dragHasFiles(event)) return;
event.preventDefault();
event.stopPropagation();
this._dragEnterCounter = 0;
this.isDragOver = false;
const files = Array.from(event.dataTransfer?.files ?? []);
if (!files.length) return;
try {
await addFilesToChat(files, {
addImages: this.addImages,
addChip: this.addChip,
});
this._trackDragDrop('file');
} catch (error) {
console.error(error);
}
};
private readonly _handleAbort = () => {
this.chatContextValue.abortController?.abort();
this.updateContext({ status: 'success' });

View File

@@ -0,0 +1,212 @@
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { CloseIcon } from '@blocksuite/icons/lit';
import { css, html, type PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
const DEFAULT_TAB_TITLE = 'New chat';
const TITLE_MAX_LENGTH = 28;
function truncate(text: string): string {
if (text.length <= TITLE_MAX_LENGTH) return text;
return `${text.slice(0, TITLE_MAX_LENGTH).trimEnd()}`;
}
function deriveTabTitle(session: CopilotChatHistoryFragment): string {
const explicit = session.title?.trim();
if (explicit) return truncate(explicit);
const firstUserMessage = session.messages?.find(m => m.role === 'user');
const raw = firstUserMessage?.content?.trim();
if (!raw) return DEFAULT_TAB_TITLE;
const newlineIdx = raw.indexOf('\n');
return truncate(newlineIdx === -1 ? raw : raw.slice(0, newlineIdx));
}
export class AIChatTabs extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor sessions: CopilotChatHistoryFragment[] = [];
@property({ attribute: false })
accessor activeSessionId: string | undefined;
@property({ attribute: false })
accessor onSelectTab!: (sessionId: string) => void;
@property({ attribute: false })
accessor onCloseTab!: (sessionId: string) => void;
static override styles = css`
ai-chat-tabs {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
height: 100%;
overflow: hidden;
}
.ai-chat-tabs {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
height: 100%;
}
.tabs-scroll {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.tabs-scroll::-webkit-scrollbar {
display: none;
}
.tab {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
max-width: 180px;
height: 26px;
padding: 0 6px 0 10px;
border-radius: 6px;
cursor: pointer;
color: ${unsafeCSSVarV2('text/secondary')};
font-size: 12px;
font-weight: 500;
user-select: none;
transition: background-color 0.15s ease;
}
.tab:hover {
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
color: ${unsafeCSSVarV2('text/primary')};
}
.tab[data-active='true'] {
background-color: ${unsafeCSSVarV2('layer/background/secondary')};
color: ${unsafeCSSVarV2('text/primary')};
}
.tab-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
padding: 0;
border-radius: 3px;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.6;
}
.tab-close:hover {
opacity: 1;
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.tab-close svg {
width: 12px;
height: 12px;
}
`;
override render() {
if (!this.sessions.length) return html``;
return html`
<div class="ai-chat-tabs" data-testid="ai-chat-tabs">
<div class="tabs-scroll" @wheel=${this._handleWheel}>
${repeat(
this.sessions,
session => session.sessionId,
session => this._renderTab(session)
)}
</div>
</div>
`;
}
private readonly _handleWheel = (e: WheelEvent) => {
const el = e.currentTarget as HTMLElement;
if (el.scrollWidth <= el.clientWidth) return;
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault();
el.scrollLeft += e.deltaY;
}
};
private _renderTab(session: CopilotChatHistoryFragment) {
const active = session.sessionId === this.activeSessionId;
const title = deriveTabTitle(session);
return html`
<div
class="tab"
data-active=${active}
data-session-id=${session.sessionId}
data-testid="ai-chat-tab"
title=${title}
@click=${() => this._handleSelect(session.sessionId)}
>
<span class="tab-title">${title}</span>
<button
class="tab-close"
data-testid="ai-chat-tab-close"
aria-label="Close tab"
@click=${(e: Event) => this._handleClose(e, session.sessionId)}
>
${CloseIcon()}
</button>
</div>
`;
}
private readonly _handleSelect = (sessionId: string) => {
if (sessionId === this.activeSessionId) return;
this.onSelectTab(sessionId);
};
private readonly _handleClose = (e: Event, sessionId: string) => {
e.stopPropagation();
this.onCloseTab(sessionId);
};
override updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (
(changedProps.has('activeSessionId') || changedProps.has('sessions')) &&
this.activeSessionId
) {
const activeTab = this.renderRoot.querySelector(
`[data-session-id="${this.activeSessionId}"]`
);
activeTab?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
}
}
}
declare global {
interface HTMLElementTagNameMap {
'ai-chat-tabs': AIChatTabs;
}
}

View File

@@ -5,7 +5,7 @@ import type { NotificationService } from '@blocksuite/affine/shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std';
import {
ArrowDownSmallIcon,
HistoryIcon,
PinedIcon,
PinIcon,
PlusIcon,
@@ -120,8 +120,9 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
<div
class="chat-toolbar-icon history-button"
@click=${this.toggleHistoryMenu}
data-testid="ai-panel-chat-history"
>
${ArrowDownSmallIcon()}
${HistoryIcon()}
<affine-tooltip>Chat History</affine-tooltip>
</div>
</div>

View File

@@ -1,3 +1,4 @@
export * from './ai-chat-tabs';
export * from './ai-chat-toolbar';
export * from './ai-session-history';
export * from './configure-ai-chat-toolbar';

View File

@@ -27,7 +27,11 @@ import { AIChatInput } from '../components/ai-chat-input';
import { AIChatEmbeddingStatusTooltip } from '../components/ai-chat-input/embedding-status-tooltip';
import { ChatInputPreference } from '../components/ai-chat-input/preference-popup';
import { AIChatMessages } from '../components/ai-chat-messages/ai-chat-messages';
import { AIChatToolbar, AISessionHistory } from '../components/ai-chat-toolbar';
import {
AIChatTabs,
AIChatToolbar,
AISessionHistory,
} from '../components/ai-chat-toolbar';
import { AIHistoryClear } from '../components/ai-history-clear';
import { AssistantAvatar } from '../components/ai-message-content/assistant-avatar';
import { ChatActionList } from '../components/chat-action-list';
@@ -53,6 +57,7 @@ const appElements = {
'action-text': ActionText,
'ai-loading': AILoading,
'ai-chat-content': AIChatContent,
'ai-chat-tabs': AIChatTabs,
'ai-chat-toolbar': AIChatToolbar,
'ai-session-history': AISessionHistory,
'ai-chat-messages': AIChatMessages,

View File

@@ -75,6 +75,7 @@ export const appEffectElementTags = [
'action-text',
'ai-loading',
'ai-chat-content',
'ai-chat-tabs',
'ai-chat-toolbar',
'ai-session-history',
'ai-chat-messages',

View File

@@ -1,5 +1,86 @@
import { WorkspaceLocalState } from '@affine/core/modules/workspace';
import type { I18nInstance } from '@affine/i18n';
import type { NotificationService } from '@blocksuite/affine/shared/services';
import { useService } from '@toeverything/infra';
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
const AI_CHAT_OPEN_TABS_KEY = 'aiChatOpenTabs';
// Pass `null` for `loadSession` to defer hydration until a real loader is ready.
export function useAIChatOpenTabs<T extends { sessionId: string }>(
loadSession: ((sessionId: string) => Promise<T | null | undefined>) | null
): {
openTabs: T[];
setOpenTabs: Dispatch<SetStateAction<T[]>>;
} {
const workspaceLocalState = useService(WorkspaceLocalState);
const [openTabs, setOpenTabsState] = useState<T[]>([]);
// Ref so persist gate isn't subject to React state-batch ordering.
const hydratedRef = useRef(false);
useEffect(() => {
if (!loadSession) return;
hydratedRef.current = false;
setOpenTabsState([]);
const ids = workspaceLocalState.get<string[]>(AI_CHAT_OPEN_TABS_KEY) ?? [];
if (!ids.length) {
hydratedRef.current = true;
return;
}
let cancelled = false;
Promise.all(ids.map(id => loadSession(id).catch(() => null)))
.then(results => {
if (cancelled) return;
const valid = (results as (T | null | undefined)[]).filter(
(entry): entry is T => !!entry && !!entry.sessionId
);
if (valid.length) setOpenTabsState(valid);
hydratedRef.current = true;
})
.catch(error => {
console.error(error);
if (!cancelled) hydratedRef.current = true;
});
return () => {
cancelled = true;
};
}, [loadSession, workspaceLocalState]);
const setOpenTabs = useCallback<Dispatch<SetStateAction<T[]>>>(
updater => {
setOpenTabsState(prev => {
const next =
typeof updater === 'function'
? (updater as (p: T[]) => T[])(prev)
: updater;
if (hydratedRef.current) {
if (next.length) {
workspaceLocalState.set(
AI_CHAT_OPEN_TABS_KEY,
next.map(tab => tab.sessionId)
);
} else {
workspaceLocalState.del(AI_CHAT_OPEN_TABS_KEY);
}
}
return next;
});
},
[workspaceLocalState]
);
return { openTabs, setOpenTabs };
}
export type SessionDeleteCleanupFn = (
session: BlockSuitePresets.AIRecentSession

View File

@@ -1,8 +1,18 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const chatTabsContainer = style({
flex: 1,
minWidth: 0,
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
});
export const chatRoot = style({
width: '100%',
height: '100%',
flex: 1,
minHeight: 0,
});
export const chatHeader = style({
@@ -10,4 +20,6 @@ export const chatHeader = style({
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
gap: 12,
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
});

View File

@@ -7,6 +7,7 @@ import {
import type { ChatStatus } from '@affine/core/blocksuite/ai/components/ai-chat-messages';
import type { AIChatToolbar } from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
import {
AIChatTabs,
configureAIChatToolbar,
getOrCreateAIChatToolbar,
} from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
@@ -49,7 +50,10 @@ import { useFramework, useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createSessionDeleteHandler } from '../chat-panel-utils';
import {
createSessionDeleteHandler,
useAIChatOpenTabs,
} from '../chat-panel-utils';
import * as styles from './index.css';
type CopilotSession = Awaited<ReturnType<CopilotClient['getSession']>>;
@@ -93,6 +97,7 @@ export const Component = () => {
const [isHeaderProvided, setIsHeaderProvided] = useState(false);
const [chatContent, setChatContent] = useState<AIChatContent | null>(null);
const [chatTool, setChatTool] = useState<AIChatToolbar | null>(null);
const [chatTabs, setChatTabs] = useState<AIChatTabs | null>(null);
const [currentSession, setCurrentSession] = useState<CopilotSession | null>(
null
);
@@ -102,12 +107,19 @@ export const Component = () => {
const hasRestoredPinnedSessionRef = useRef(false);
const chatContainerRef = useRef<HTMLDivElement>(null);
const chatToolContainerRef = useRef<HTMLDivElement>(null);
const chatTabsContainerRef = useRef<HTMLDivElement | null>(null);
const widthSignalRef = useRef<Signal<number>>(signal(0));
const client = useCopilotClient();
const workbench = useService(WorkbenchService).workbench;
const workspaceId = useService(WorkspaceService).workspace.id;
const loadSession = useCallback(
(sessionId: string) => client.getSession(workspaceId, sessionId),
[client, workspaceId]
);
const { openTabs, setOpenTabs } = useAIChatOpenTabs(loadSession);
useEffect(() => {
hasRestoredPinnedSessionRef.current = false;
}, [workspaceId]);
@@ -192,6 +204,11 @@ export const Component = () => {
setIsOpeningSession(true);
try {
const session = await client.getSession(workspaceId, sessionId);
if (!session) {
// Drop stale tab if session no longer exists.
setOpenTabs(prev => prev.filter(tab => tab.sessionId !== sessionId));
return;
}
setCurrentSession(session);
reMountChatContent();
chatTool?.closeHistoryMenu();
@@ -207,10 +224,31 @@ export const Component = () => {
currentSession?.sessionId,
isOpeningSession,
reMountChatContent,
setOpenTabs,
workspaceId,
]
);
const closeTab = useCallback(
(sessionId: string) => {
let fallback: NonNullable<CopilotSession> | undefined;
setOpenTabs(prev => {
const idx = prev.findIndex(tab => tab.sessionId === sessionId);
if (idx === -1) return prev;
const next = prev.filter(tab => tab.sessionId !== sessionId);
fallback = next[idx] ?? next[idx - 1] ?? next[0];
return next;
});
if (currentSession?.sessionId !== sessionId) return;
if (fallback) {
onOpenSession(fallback.sessionId).catch(console.error);
} else {
createFreshSession().catch(console.error);
}
},
[createFreshSession, currentSession?.sessionId, onOpenSession, setOpenTabs]
);
const onContextChange = useCallback((context: Partial<ChatContextValue>) => {
setStatus(context.status ?? 'idle');
}, []);
@@ -399,6 +437,40 @@ export const Component = () => {
return () => sub.unsubscribe();
}, [framework, mockStd]);
useEffect(() => {
if (!currentSession?.sessionId) return;
setOpenTabs(prev => {
const existing = prev.findIndex(
tab => tab.sessionId === currentSession.sessionId
);
if (existing !== -1) {
if (prev[existing] === currentSession) return prev;
const next = prev.slice();
next[existing] = currentSession;
return next;
}
return [...prev, currentSession];
});
}, [currentSession, setOpenTabs]);
useEffect(() => {
if (!chatTabsContainerRef.current) return;
let tabs = chatTabs;
if (!tabs) {
tabs = new AIChatTabs();
chatTabsContainerRef.current.append(tabs);
setChatTabs(tabs);
}
tabs.sessions = openTabs;
tabs.activeSessionId = currentSession?.sessionId;
tabs.onSelectTab = (sessionId: string) => {
onOpenSession(sessionId).catch(console.error);
};
tabs.onCloseTab = (sessionId: string) => {
closeTab(sessionId);
};
}, [chatTabs, closeTab, currentSession?.sessionId, onOpenSession, openTabs]);
// restore pinned session
useEffect(() => {
if (hasRestoredPinnedSessionRef.current || currentSession) return;
@@ -462,6 +534,10 @@ export const Component = () => {
}
}, []);
const onChatTabsContainerRef = useCallback((node: HTMLDivElement | null) => {
chatTabsContainerRef.current = node;
}, []);
// observe chat container width and provide to ai-chat-content
useEffect(() => {
if (!isBodyProvided || !chatContainerRef.current) return;
@@ -476,7 +552,10 @@ export const Component = () => {
<ViewIcon icon="ai" />
<ViewHeader>
<div className={styles.chatHeader}>
<div />
<div
className={styles.chatTabsContainer}
ref={onChatTabsContainerRef}
/>
<div ref={onChatToolContainerRef} />
</div>
</ViewHeader>

View File

@@ -20,11 +20,13 @@ export const header = style({
position: 'relative',
padding: '8px var(--h-padding, 16px)',
width: '100%',
height: '36px',
minHeight: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
zIndex: 1,
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
});
export const title = style({
@@ -82,6 +84,14 @@ export const loadingIcon = style({
color: 'var(--affine-icon-secondary)',
});
export const tabsContainer = style({
flex: 1,
minWidth: 0,
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
});
globalStyle(`${playground} svg`, {
width: '18px',
height: '18px',

View File

@@ -8,6 +8,7 @@ import {
import type { ChatStatus } from '@affine/core/blocksuite/ai/components/ai-chat-messages';
import type { AIChatToolbar } from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
import {
AIChatTabs,
configureAIChatToolbar,
getOrCreateAIChatToolbar,
} from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
@@ -45,7 +46,10 @@ import { useFramework, useService } from '@toeverything/infra';
import { html } from 'lit';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createSessionDeleteHandler } from '../../chat-panel-utils';
import {
createSessionDeleteHandler,
useAIChatOpenTabs,
} from '../../chat-panel-utils';
import * as styles from './chat.css';
import {
getChatContentKey,
@@ -93,11 +97,12 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
const [chatContent, setChatContent] = useState<AIChatContent | null>(null);
const [chatToolbar, setChatToolbar] = useState<AIChatToolbar | null>(null);
const [chatTabs, setChatTabs] = useState<AIChatTabs | null>(null);
const [isBodyProvided, setIsBodyProvided] = useState(false);
const [isHeaderProvided, setIsHeaderProvided] = useState(false);
const chatContainerRef = useRef<HTMLDivElement | null>(null);
const chatToolbarContainerRef = useRef<HTMLDivElement | null>(null);
const chatTabsContainerRef = useRef<HTMLDivElement | null>(null);
const contentKeyRef = useRef<string | null>(null);
const prevSessionIdRef = useRef<string | null>(null);
const prevSessionDocIdRef = useRef<string | null>(null);
@@ -107,6 +112,36 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
const doc = editor?.doc;
const host = editor?.host;
const workspaceId = doc?.workspace.id;
const [sessionServiceReady, setSessionServiceReady] = useState(
() => !!AIProvider.session
);
useEffect(() => {
if (sessionServiceReady) return;
if (AIProvider.session) {
setSessionServiceReady(true);
return;
}
const sub = AIProvider.slots.sessionReady.subscribe(ready => {
if (ready) setSessionServiceReady(true);
});
return () => sub.unsubscribe();
}, [sessionServiceReady]);
const loadSession = useMemo(() => {
if (!sessionServiceReady || !workspaceId) return null;
const sessionService = AIProvider.session;
if (!sessionService) return null;
return async (
sessionId: string
): Promise<CopilotChatHistoryFragment | null | undefined> =>
sessionService.getSession(workspaceId, sessionId);
}, [sessionServiceReady, workspaceId]);
const { openTabs, setOpenTabs } =
useAIChatOpenTabs<CopilotChatHistoryFragment>(loadSession);
const appSidebarConfig = useMemo<AppSidebarConfig>(() => {
return {
@@ -237,13 +272,18 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
sessionId
);
if (requestSeq !== sessionLoadSeqRef.current) return;
setSession(nextSession ?? null);
setHasPinned(!!nextSession?.pinned);
if (!nextSession) {
// Drop stale tab if session no longer exists.
setOpenTabs(prev => prev.filter(tab => tab.sessionId !== sessionId));
return;
}
setSession(nextSession);
setHasPinned(!!nextSession.pinned);
} catch (error) {
console.error(error);
}
},
[doc, session?.sessionId]
[doc, session?.sessionId, setOpenTabs]
);
const openDoc = useCallback(
@@ -291,6 +331,26 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
[newSession, notificationService, session?.sessionId, t]
);
const closeTab = useCallback(
(sessionId: string) => {
let fallback: CopilotChatHistoryFragment | undefined;
setOpenTabs(prev => {
const idx = prev.findIndex(tab => tab.sessionId === sessionId);
if (idx === -1) return prev;
const next = prev.filter(tab => tab.sessionId !== sessionId);
fallback = next[idx] ?? next[idx - 1] ?? next[0];
return next;
});
if (session?.sessionId !== sessionId) return;
if (fallback) {
openSession(fallback.sessionId).catch(console.error);
} else {
newSession().catch(console.error);
}
},
[newSession, openSession, session?.sessionId, setOpenTabs]
);
const togglePin = useCallback(async () => {
const pinned = !session?.pinned;
setHasPinned(true);
@@ -347,7 +407,27 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
chatToolbar.remove();
setChatToolbar(null);
}
}, [chatContent, chatToolbar, session]);
if (chatTabs) {
chatTabs.remove();
setChatTabs(null);
}
}, [chatContent, chatTabs, chatToolbar, session]);
useEffect(() => {
if (!session?.sessionId) return;
setOpenTabs(prev => {
const existing = prev.findIndex(
tab => tab.sessionId === session.sessionId
);
if (existing !== -1) {
if (prev[existing] === session) return prev;
const next = prev.slice();
next[existing] = session;
return next;
}
return [...prev, session];
});
}, [session, setOpenTabs]);
useEffect(() => {
let disposed = false;
@@ -553,6 +633,30 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
togglePin,
]);
useEffect(() => {
if (!chatTabsContainerRef.current || !doc) {
return;
}
if (session === undefined) {
return;
}
let tabs = chatTabs;
if (!tabs) {
tabs = new AIChatTabs();
chatTabsContainerRef.current.append(tabs);
setChatTabs(tabs);
}
tabs.sessions = openTabs;
tabs.activeSessionId = session?.sessionId;
tabs.onSelectTab = (sessionId: string) => {
openSession(sessionId).catch(console.error);
};
tabs.onCloseTab = (sessionId: string) => {
closeTab(sessionId);
};
}, [chatTabs, closeTab, doc, openSession, openTabs, session]);
useEffect(() => {
if (!editor?.host || !chatContent) {
return;
@@ -654,6 +758,10 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
chatToolbarContainerRef.current = node;
}, []);
const onChatTabsContainerRef = useCallback((node: HTMLDivElement | null) => {
chatTabsContainerRef.current = node;
}, []);
const isEmbedding =
embeddingProgress[1] > 0 && embeddingProgress[0] < embeddingProgress[1];
const [done, total] = embeddingProgress;
@@ -690,6 +798,10 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
<CenterPeekIcon />
</div>
) : null}
<div
className={styles.tabsContainer}
ref={onChatTabsContainerRef}
/>
<div ref={onChatToolContainerRef} />
</div>
<div className={styles.content} ref={onChatContainerRef} />

View File

@@ -35,6 +35,7 @@ const ToggleButton = ({
className={className}
data-show={show}
data-testid="right-sidebar-toggle"
tooltip="Open sidebar"
>
<RightSidebarIcon />
</IconButton>

View File

@@ -1,8 +1,30 @@
import { IconButton } from '@affine/component';
import { RightSidebarIcon } from '@blocksuite/icons/rc';
import * as styles from './sidebar-header.css';
const RightSidebarOpenIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="1em"
height="1em"
fill="none"
style={{ userSelect: 'none', flexShrink: 0 }}
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M15.25 6h3.25a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-.5.5h-3.25zm-1.5 0H5.5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h8.25zM3.5 6.5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"
clipRule="evenodd"
/>
<path
fill="#1E96EB"
d="M15.25 6h3.25a.5.5 0 0 1 .5.5v11a.5.5 0 0 1-.5.5h-3.25z"
/>
</svg>
);
export type HeaderProps = {
onToggle?: () => void;
children?: React.ReactNode;
@@ -26,8 +48,13 @@ function Container({
const ToggleButton = ({ onToggle }: { onToggle?: () => void }) => {
return (
<IconButton size="24" onClick={onToggle} data-testid="right-sidebar-close">
<RightSidebarIcon />
<IconButton
size="24"
onClick={onToggle}
data-testid="right-sidebar-close"
tooltip="Close sidebar"
>
<RightSidebarOpenIcon />
</IconButton>
);
};

View File

@@ -721,7 +721,7 @@ export type EventArgs = {
dragStart: { type: string };
addEmbeddingDoc: {
type?: 'page' | 'edgeless';
control: 'addButton' | 'atMenu';
control: 'addButton' | 'atMenu' | 'dragDrop';
method: 'doc' | 'cur-doc' | 'file' | 'tags' | 'collections' | 'suggestion';
};
openAttachmentInFullscreen: AttachmentEventArgs;

View File

@@ -385,6 +385,7 @@ __metadata:
"@affine/reader": "workspace:*"
"@affine/templates": "workspace:*"
"@affine/track": "workspace:*"
"@atlaskit/pragmatic-drag-and-drop": "npm:^1.7.7"
"@blocksuite/affine": "workspace:*"
"@blocksuite/affine-block-root": "workspace:*"
"@blocksuite/affine-components": "workspace:*"