mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-05-08 22:07:32 +08:00
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:
@@ -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}`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './attachment-utils';
|
||||
export * from './type';
|
||||
export * from './utils';
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}`,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -35,6 +35,7 @@ const ToggleButton = ({
|
||||
className={className}
|
||||
data-show={show}
|
||||
data-testid="right-sidebar-toggle"
|
||||
tooltip="Open sidebar"
|
||||
>
|
||||
<RightSidebarIcon />
|
||||
</IconButton>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
Reference in New Issue
Block a user