mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-18 10:31:50 +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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user