From 440ff0c342c54ac2ef482ccdaf989d6cba788007 Mon Sep 17 00:00:00 2001 From: Adarsh Singh Date: Thu, 7 May 2026 01:34:43 +0530 Subject: [PATCH] fix(editor): resolve UX inconsistencies in the AI chat interface (#14850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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. ## 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. --------- Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com> --- .../affine/components/src/tooltip/tooltip.ts | 15 +- packages/frontend/core/package.json | 1 + .../components/ai-chat-chips/add-popover.ts | 21 +- .../ai-chat-chips/attachment-utils.ts | 36 +++ .../ai/components/ai-chat-chips/index.ts | 1 + .../components/ai-chat-input/ai-chat-input.ts | 170 ++++++++++++++ .../ai-chat-toolbar/ai-chat-tabs.ts | 212 ++++++++++++++++++ .../ai-chat-toolbar/ai-chat-toolbar.ts | 5 +- .../ai/components/ai-chat-toolbar/index.ts | 1 + .../core/src/blocksuite/ai/effects/app.ts | 7 +- .../src/blocksuite/ai/effects/registry.ts | 1 + .../pages/workspace/chat-panel-utils.ts | 81 +++++++ .../desktop/pages/workspace/chat/index.css.ts | 14 +- .../desktop/pages/workspace/chat/index.tsx | 83 ++++++- .../workspace/detail-page/tabs/chat.css.ts | 12 +- .../pages/workspace/detail-page/tabs/chat.tsx | 124 +++++++++- .../workbench/view/route-container.tsx | 1 + .../workbench/view/sidebar/sidebar-header.tsx | 33 ++- packages/frontend/track/src/events.ts | 2 +- yarn.lock | 1 + 20 files changed, 781 insertions(+), 40 deletions(-) create mode 100644 packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/attachment-utils.ts create mode 100644 packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-tabs.ts diff --git a/blocksuite/affine/components/src/tooltip/tooltip.ts b/blocksuite/affine/components/src/tooltip/tooltip.ts index fffe48f22..929edc5f2 100644 --- a/blocksuite/affine/components/src/tooltip/tooltip.ts +++ b/blocksuite/affine/components/src/tooltip/tooltip.ts @@ -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}`, }, }; diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index eb6f2323a..7d33e098b 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -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:*", diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/add-popover.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/add-popover.ts index 7e08a2d10..d7e18657f 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/add-popover.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/add-popover.ts @@ -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'); }; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/attachment-utils.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/attachment-utils.ts new file mode 100644 index 000000000..08a0b4dad --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/attachment-utils.ts @@ -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; +} + +export async function addFilesToChat( + files: File[], + { addImages, addChip }: AttachmentHandlers +): Promise { + 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', + }); + }) + ); +} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/index.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/index.ts index f7e6c3718..009abb6a6 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/index.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/index.ts @@ -1,2 +1,3 @@ +export * from './attachment-utils'; export * from './type'; export * from './utils'; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts index 6d57f23aa..a27aaeee3 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-input/ai-chat-input.ts @@ -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`
Drop to attach
` + : nothing} ${hasImages ? html` { + 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' }); diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-tabs.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-tabs.ts new file mode 100644 index 000000000..dc17d3e15 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-tabs.ts @@ -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` +
+
+ ${repeat( + this.sessions, + session => session.sessionId, + session => this._renderTab(session) + )} +
+
+ `; + } + + 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` +
this._handleSelect(session.sessionId)} + > + ${title} + +
+ `; + } + + 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; + } +} diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-toolbar.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-toolbar.ts index 9ba8fcf55..06c3e5a41 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-toolbar.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/ai-chat-toolbar.ts @@ -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) {
- ${ArrowDownSmallIcon()} + ${HistoryIcon()} Chat History
diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/index.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/index.ts index 3d6378ec3..9ae9f576f 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/index.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-toolbar/index.ts @@ -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'; diff --git a/packages/frontend/core/src/blocksuite/ai/effects/app.ts b/packages/frontend/core/src/blocksuite/ai/effects/app.ts index dd8acbb47..bd7e524fe 100644 --- a/packages/frontend/core/src/blocksuite/ai/effects/app.ts +++ b/packages/frontend/core/src/blocksuite/ai/effects/app.ts @@ -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, diff --git a/packages/frontend/core/src/blocksuite/ai/effects/registry.ts b/packages/frontend/core/src/blocksuite/ai/effects/registry.ts index f113f393c..d696a58d5 100644 --- a/packages/frontend/core/src/blocksuite/ai/effects/registry.ts +++ b/packages/frontend/core/src/blocksuite/ai/effects/registry.ts @@ -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', diff --git a/packages/frontend/core/src/desktop/pages/workspace/chat-panel-utils.ts b/packages/frontend/core/src/desktop/pages/workspace/chat-panel-utils.ts index 7d79ec385..13c692c7e 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/chat-panel-utils.ts +++ b/packages/frontend/core/src/desktop/pages/workspace/chat-panel-utils.ts @@ -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( + loadSession: ((sessionId: string) => Promise) | null +): { + openTabs: T[]; + setOpenTabs: Dispatch>; +} { + const workspaceLocalState = useService(WorkspaceLocalState); + const [openTabs, setOpenTabsState] = useState([]); + // 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(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>>( + 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 diff --git a/packages/frontend/core/src/desktop/pages/workspace/chat/index.css.ts b/packages/frontend/core/src/desktop/pages/workspace/chat/index.css.ts index d72cd8071..be034e27b 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/chat/index.css.ts +++ b/packages/frontend/core/src/desktop/pages/workspace/chat/index.css.ts @@ -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')}`, }); diff --git a/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx index f11869a36..0f1e90933 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/chat/index.tsx @@ -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>; @@ -93,6 +97,7 @@ export const Component = () => { const [isHeaderProvided, setIsHeaderProvided] = useState(false); const [chatContent, setChatContent] = useState(null); const [chatTool, setChatTool] = useState(null); + const [chatTabs, setChatTabs] = useState(null); const [currentSession, setCurrentSession] = useState( null ); @@ -102,12 +107,19 @@ export const Component = () => { const hasRestoredPinnedSessionRef = useRef(false); const chatContainerRef = useRef(null); const chatToolContainerRef = useRef(null); + const chatTabsContainerRef = useRef(null); const widthSignalRef = useRef>(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 | 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) => { 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 = () => {
-
+
diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.css.ts b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.css.ts index 94016894a..cd4f8ab24 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.css.ts +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.css.ts @@ -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', diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx index ebad7f6d4..825484296 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx @@ -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(null); const [chatToolbar, setChatToolbar] = useState(null); + const [chatTabs, setChatTabs] = useState(null); const [isBodyProvided, setIsBodyProvided] = useState(false); const [isHeaderProvided, setIsHeaderProvided] = useState(false); - const chatContainerRef = useRef(null); const chatToolbarContainerRef = useRef(null); + const chatTabsContainerRef = useRef(null); const contentKeyRef = useRef(null); const prevSessionIdRef = useRef(null); const prevSessionDocIdRef = useRef(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 => + sessionService.getSession(workspaceId, sessionId); + }, [sessionServiceReady, workspaceId]); + + const { openTabs, setOpenTabs } = + useAIChatOpenTabs(loadSession); const appSidebarConfig = useMemo(() => { 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) => {
) : null} +
diff --git a/packages/frontend/core/src/modules/workbench/view/route-container.tsx b/packages/frontend/core/src/modules/workbench/view/route-container.tsx index 85a2c3335..bf29f2ab0 100644 --- a/packages/frontend/core/src/modules/workbench/view/route-container.tsx +++ b/packages/frontend/core/src/modules/workbench/view/route-container.tsx @@ -35,6 +35,7 @@ const ToggleButton = ({ className={className} data-show={show} data-testid="right-sidebar-toggle" + tooltip="Open sidebar" > diff --git a/packages/frontend/core/src/modules/workbench/view/sidebar/sidebar-header.tsx b/packages/frontend/core/src/modules/workbench/view/sidebar/sidebar-header.tsx index bb05e7f19..27fe69eb5 100644 --- a/packages/frontend/core/src/modules/workbench/view/sidebar/sidebar-header.tsx +++ b/packages/frontend/core/src/modules/workbench/view/sidebar/sidebar-header.tsx @@ -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) => ( + + + + +); + export type HeaderProps = { onToggle?: () => void; children?: React.ReactNode; @@ -26,8 +48,13 @@ function Container({ const ToggleButton = ({ onToggle }: { onToggle?: () => void }) => { return ( - - + + ); }; diff --git a/packages/frontend/track/src/events.ts b/packages/frontend/track/src/events.ts index ba1f1f57e..770b6b96a 100644 --- a/packages/frontend/track/src/events.ts +++ b/packages/frontend/track/src/events.ts @@ -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; diff --git a/yarn.lock b/yarn.lock index 8d2ae7165..e4139baa1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:*"