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

# Closes #14189.

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

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

---

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---

## Commits

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

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

---

## Test plan

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

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

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

## Out of scope / follow-ups

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


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

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

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

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

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

---------

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