feat(webui): add tabbed pane workbench

This commit is contained in:
chengyongru
2026-08-10 18:43:07 +08:00
parent c281e090d0
commit 7ddd051161
27 changed files with 3118 additions and 196 deletions
+392 -71
View File
@@ -12,8 +12,26 @@ import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react"
import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { Sidebar } from "@/components/Sidebar";
import type { SidebarDeleteItem } from "@/components/ChatList";
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
import {
WORKBENCH_STORAGE_KEY,
MAX_WORKBENCH_PANES,
addWorkbenchPane,
attachWorkbenchPane,
detachWorkbenchPane,
ensureWorkbenchTab,
focusWorkbenchPane,
parseWorkbenchState,
promoteWorkbenchPane,
reconcileWorkbench,
setWorkbenchLayout,
workbenchChildPaneKeys,
workbenchTab,
type WorkbenchState,
} from "@/components/workbench/workbench-model";
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
@@ -120,6 +138,14 @@ const RenameChatDialog = lazy(async () => {
return { default: module.RenameChatDialog };
});
function readWorkbenchState(): WorkbenchState {
try {
return parseWorkbenchState(window.localStorage.getItem(WORKBENCH_STORAGE_KEY));
} catch {
return parseWorkbenchState(null);
}
}
function SurfaceLoadingFallback() {
const { t } = useTranslation();
return (
@@ -1034,9 +1060,18 @@ function Shell({
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
const [workbenchState, setWorkbenchState] = useState(readWorkbenchState);
const [creatingPane, setCreatingPane] = useState(false);
const childPaneKeys = useMemo(
() => workbenchChildPaneKeys(workbenchState),
[workbenchState],
);
const topicSessions = useMemo(
() => sessions.filter((session) => !childPaneKeys.has(session.key)),
[childPaneKeys, sessions],
);
const [pendingDelete, setPendingDelete] = useState<{
key: string;
label: string;
items: SidebarDeleteItem[];
automations?: SessionAutomationJob[];
} | null>(null);
const [pendingRename, setPendingRename] = useState<{
@@ -1157,6 +1192,17 @@ function Shell({
}
}, [hostSidebarOpen]);
useEffect(() => {
try {
window.localStorage.setItem(
WORKBENCH_STORAGE_KEY,
JSON.stringify(workbenchState),
);
} catch {
// ignore storage errors (private mode, etc.)
}
}, [workbenchState]);
useEffect(() => {
writeSessionUpdateChatIds(updatedChatIds);
}, [updatedChatIds]);
@@ -1220,9 +1266,19 @@ function Shell({
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey, temporarySessions]);
const activeTabState = useMemo(() => (
activeKey && !temporarySessions[activeKey]
? workbenchTab(workbenchState, activeKey)
: null
), [activeKey, temporarySessions, workbenchState]);
const activePaneSession = useMemo<ChatSummary | null>(() => {
if (!activeTabState) return activeSession;
return sessions.find((session) => session.key === activeTabState.activePaneKey)
?? activeSession;
}, [activeSession, activeTabState, sessions]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null;
const activeChatId = activePaneSession?.chatId ?? null;
useEffect(() => {
activeChatIdRef.current = activeChatId;
if (!activeChatId) return;
@@ -1242,13 +1298,13 @@ function Shell({
if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId];
}
if (activeSession?.workspaceScope) {
return activeSession.workspaceScope;
if (activePaneSession?.workspaceScope) {
return activePaneSession.workspaceScope;
}
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
}, [
activeChatId,
activeSession?.workspaceScope,
activePaneSession?.workspaceScope,
draftWorkspaceScope,
temporaryChatRequested,
workspaceOverrides,
@@ -1284,6 +1340,18 @@ function Shell({
});
}, [loading, sessions]);
useEffect(() => {
if (loading) return;
const validKeys = new Set(sessions.map((session) => session.key));
setWorkbenchState((current) => {
const reconciled = reconcileWorkbench(current, validKeys);
if (!activeKey || temporarySessions[activeKey] || !validKeys.has(activeKey)) {
return reconciled;
}
return ensureWorkbenchTab(reconciled, activeKey);
});
}, [activeKey, loading, sessions, temporarySessions]);
useEffect(() => {
if (loading) return;
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
@@ -1788,7 +1856,7 @@ function Shell({
});
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
const archived = new Set([...sidebarState.archived_keys, key]);
const next = sessions.find((session) => !archived.has(session.key));
const next = topicSessions.find((session) => !archived.has(session.key));
navigate({
view: "chat",
activeKey: next?.key ?? null,
@@ -1796,7 +1864,7 @@ function Shell({
});
}
},
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
[activeKey, navigate, sidebarState.archived_keys, topicSessions, updateSidebarState],
);
const onReorderSessions = useCallback(
@@ -1825,6 +1893,47 @@ function Shell({
setSessionSearchOpen(true);
}, []);
const onAddPane = useCallback(async () => {
const tabKey = activeKey;
if (
!tabKey
|| !activeSession
|| creatingPane
|| (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES
|| temporarySessionsRef.current[tabKey]
) return;
setMobileSidebarOpen(false);
setSessionSearchOpen(false);
setCreatingPane(true);
try {
const scope = activeWorkspaceScope;
const chatId = await createChat(scope);
const paneKey = `websocket:${chatId}`;
setWorkbenchState((current) => addWorkbenchPane(current, tabKey, paneKey));
if (scope) {
setWorkspaceOverrides((current) => ({
...current,
[chatId]: normalizeWorkspaceScope(scope),
}));
}
} catch (error) {
console.error("Failed to create pane", error);
if (error instanceof Error && error.message.startsWith("workspace_scope_rejected:")) {
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
}
} finally {
setCreatingPane(false);
}
}, [
activeKey,
activeSession,
activeTabState,
activeWorkspaceScope,
createChat,
creatingPane,
t,
]);
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented) return;
@@ -1902,15 +2011,15 @@ function Shell({
setMobileSidebarOpen(false);
const nextKey = (() => {
if (!activeKey) return null;
if (sessions.some((session) => session.key === activeKey)) return activeKey;
return sessions[0]?.key ?? null;
if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
return topicSessions[0]?.key ?? null;
})();
navigate({
view: "chat",
activeKey: nextKey,
settingsSection: "overview",
});
}, [activeKey, navigate, sessions]);
}, [activeKey, navigate, topicSessions]);
const onRestart = useCallback(() => {
const chatId = activeSession?.chatId ?? client.defaultChatId;
@@ -2017,30 +2126,42 @@ function Shell({
}, [client, t]);
const onTurnEnd = useDeferredTitleRefresh(
temporaryChatActive ? null : activeSession,
temporaryChatActive ? null : activePaneSession,
refresh,
);
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
const key = pendingDelete.key;
const items = pendingDelete.items;
const deletingKeys = new Set(items.map((item) => item.key));
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
const deletingActive = activeKey === key;
const currentIndex = sessions.findIndex((s) => s.key === key);
const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
const fallbackKey = deletingActive
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
? (
topicSessions.slice(currentIndex + 1).find((session) => (
!deletingKeys.has(session.key)
))?.key
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
!deletingKeys.has(session.key)
))?.key
?? null
)
: activeKey;
try {
const result = await deleteChat(
key,
hasAutomations ? { deleteAutomations: true } : undefined,
);
if (result.blocked_by_automations) {
setPendingDelete({
...pendingDelete,
automations: result.automations ?? [],
});
return;
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const result = await deleteChat(
item.key,
hasAutomations ? { deleteAutomations: true } : undefined,
);
if (result.blocked_by_automations) {
setPendingDelete({
items: items.slice(index),
automations: result.automations ?? [],
});
return;
}
}
setPendingDelete(null);
if (deletingActive) {
@@ -2053,18 +2174,24 @@ function Shell({
} catch (e) {
console.error("Failed to delete session", e);
}
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
}, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]);
const onRequestDelete = useCallback(async (key: string, label: string) => {
let automations: SessionAutomationJob[] = [];
try {
automations = await getSessionAutomations(key);
} catch {
// Delete remains protected by the backend block; prefetch only improves the first prompt.
}
setPendingDelete({ key, label, automations });
const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
if (uniqueItems.length === 0) return;
const automationResults = await Promise.allSettled(
uniqueItems.map((item) => getSessionAutomations(item.key)),
);
const automations = automationResults.flatMap((result) => (
result.status === "fulfilled" ? result.value : []
));
setPendingDelete({ items: uniqueItems, automations });
}, [getSessionAutomations]);
const onRequestDelete = useCallback((key: string, label: string) => {
void onRequestDeleteMany([{ key, label }]);
}, [onRequestDeleteMany]);
const visiblePairingRequests = useMemo(
() => {
const now = Date.now();
@@ -2109,13 +2236,117 @@ function Shell({
});
}, []);
const titleForSession = useCallback((session: ChatSummary) => (
sidebarState.title_overrides[session.key]
|| session.title
|| deriveTitle(session.preview, t("chat.newChat"))
), [sidebarState.title_overrides, t]);
const headerTitle = temporaryChatActive
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
: activeSession
? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat"))
? titleForSession(activeSession)
: t("app.brand");
const workbenchPaneSessions = useMemo(() => {
if (!activeTabState) return [];
const byKey = new Map(sessions.map((session) => [session.key, session]));
return activeTabState.paneKeys
.map((key) => byKey.get(key))
.filter((session): session is ChatSummary => session !== undefined);
}, [activeTabState, sessions]);
const paneChromeEnabled = Boolean(
activeKey && activeSession && !temporaryChatActive && activeTabState,
);
const renderedWorkbenchPanes = useMemo(() => {
if (paneChromeEnabled && activeKey) {
return workbenchPaneSessions.map((session) => ({
key: session.key,
reactKey: session.key === activeKey ? "tab-root" : `pane:${session.key}`,
title: titleForSession(session),
}));
}
return [{
key: activeKey ?? "new-topic",
reactKey: "tab-root",
title: headerTitle,
}];
}, [activeKey, headerTitle, paneChromeEnabled, titleForSession, workbenchPaneSessions]);
const renderedActivePaneKey = paneChromeEnabled && activeTabState
? activeTabState.activePaneKey
: renderedWorkbenchPanes[0].key;
const renderedWorkbenchLayout = paneChromeEnabled && activeTabState
? activeTabState.layout
: "columns";
const sidebarPaneGroups = useMemo(() => {
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
return Object.fromEntries(topicSessions.map((topic) => {
const tab = workbenchTab(workbenchState, topic.key);
const panes = tab.paneKeys
.map((key) => sessionsByKey.get(key))
.filter((session): session is ChatSummary => session !== undefined)
.map((session) => ({
key: session.key,
chatId: session.chatId,
title: titleForSession(session),
}));
return [topic.key, {
topicKey: topic.key,
activePaneKey: tab.activePaneKey,
panes,
}];
}));
}, [sessions, titleForSession, topicSessions, workbenchState]);
const attachableTabKeys = useMemo(() => (
topicSessions
.filter((session) => workbenchTab(workbenchState, session.key).paneKeys.length === 1)
.map((session) => session.key)
), [topicSessions, workbenchState]);
const paneAcceptingTabKeys = useMemo(() => (
topicSessions
.filter((session) => (
workbenchTab(workbenchState, session.key).paneKeys.length < MAX_WORKBENCH_PANES
))
.map((session) => session.key)
), [topicSessions, workbenchState]);
const activePaneLimitReached = Boolean(
activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES,
);
const onActivateWorkbenchPane = useCallback((paneKey: string) => {
if (!activeKey) return;
setWorkbenchState((current) => focusWorkbenchPane(current, activeKey, paneKey));
}, [activeKey]);
const onSelectSidebarPane = useCallback((tabKey: string, paneKey: string) => {
setWorkbenchState((current) => focusWorkbenchPane(current, tabKey, paneKey));
if (activeKey !== tabKey) {
navigate({
view: "chat",
activeKey: tabKey,
settingsSection: "overview",
});
}
}, [activeKey, navigate]);
const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
setWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey));
}, []);
const onPromoteWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
setWorkbenchState((current) => promoteWorkbenchPane(current, tabKey, paneKey));
}, []);
const onAttachWorkbenchPane = useCallback((paneKey: string, tabKey: string) => {
if (paneKey === tabKey) return;
setWorkbenchState((current) => attachWorkbenchPane(current, tabKey, paneKey));
if (activeKey === paneKey) {
navigate({
view: "chat",
activeKey: tabKey,
settingsSection: "overview",
});
}
}, [activeKey, navigate]);
useEffect(() => {
if (view === "settings") {
@@ -2148,7 +2379,7 @@ function Shell({
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
const sidebarProps = {
sessions,
sessions: topicSessions,
temporarySessions: temporarySessionList,
activeKey: view === "chat" ? activeKey : null,
loading,
@@ -2157,9 +2388,17 @@ function Shell({
onSelect: onSelectChat,
onCloseTemporaryChat,
onRequestDelete,
onRequestDeleteMany,
onTogglePin,
onRequestRename,
onToggleArchive,
paneGroups: sidebarPaneGroups,
onSelectPane: onSelectSidebarPane,
onDetachPane: onDetachWorkbenchPane,
onPromotePane: onPromoteWorkbenchPane,
attachableTabKeys,
paneAcceptingTabKeys,
onAttachPane: onAttachWorkbenchPane,
onReorderSessions,
onToggleGroup,
onRequestRenameProject,
@@ -2182,7 +2421,9 @@ function Shell({
updatedChatIds: updatedChatIdList,
viewState: sidebarState.view,
showArchived: sidebarState.view.show_archived,
archivedCount: sidebarState.archived_keys.length,
archivedCount: topicSessions.filter(
(session) => sidebarState.archived_keys.includes(session.key),
).length,
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
};
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
@@ -2318,7 +2559,7 @@ function Shell({
<SessionSearchDialog
open
onOpenChange={setSessionSearchOpen}
sessions={sessions}
sessions={topicSessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
@@ -2337,35 +2578,114 @@ function Shell({
view !== "chat" && "hidden",
)}
>
<ThreadShell
session={activeSession}
sessions={sessions}
title={headerTitle}
temporary={temporaryChatRequested}
temporaryChatIds={temporaryChatIds}
temporaryChatEnabled={temporaryChatEnabled}
onTemporaryChatEnabledChange={
!activeKey ? onTemporaryChatEnabledChange : undefined
}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
onForkChat={temporaryChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleForHostChrome
hostChromeTitleInset={hostSidebarCollapsed}
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null}
workspaceScopeDisabled={activeChatRunning}
workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
skills={skills}
<PaneWorkbench
panes={renderedWorkbenchPanes}
activePaneKey={renderedActivePaneKey}
layout={renderedWorkbenchLayout}
chrome={paneChromeEnabled}
addPaneDisabled={creatingPane || activePaneLimitReached}
onActivatePane={onActivateWorkbenchPane}
onAddPane={onAddPane}
onLayoutChange={(layout) => {
if (!activeKey) return;
setWorkbenchState((current) => (
setWorkbenchLayout(current, activeKey, layout)
));
}}
renderPane={(pane, context) => {
if (!paneChromeEnabled) {
return (
<ThreadShell
session={activeSession}
sessions={sessions}
title={headerTitle}
temporary={temporaryChatRequested}
temporaryChatIds={temporaryChatIds}
temporaryChatEnabled={temporaryChatEnabled}
onTemporaryChatEnabledChange={
!activeKey ? onTemporaryChatEnabledChange : undefined
}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={
temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat
}
onForkChat={temporaryChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleForHostChrome
hostChromeTitleInset={hostSidebarCollapsed}
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null}
workspaceScopeDisabled={activeChatRunning}
workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
skills={skills}
/>
);
}
const paneSession = workbenchPaneSessions.find(
(session) => session.key === pane.key,
);
if (!paneSession) return null;
const paneScope = workspaceOverrides[paneSession.chatId]
?? paneSession.workspaceScope
?? workspaces?.default_scope
?? null;
const paneRunning = runningChatIds.has(paneSession.chatId);
return (
<ThreadShell
session={paneSession}
sessions={sessions}
title={pane.title}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onForkChat={onForkChat}
onTurnEnd={context.active ? onTurnEnd : () => void refresh()}
theme={theme}
onToggleTheme={toggle}
hideSidebarToggle={!context.active}
hideSidebarToggleForHostChrome={context.active}
hostChromeTitleInset={hostSidebarCollapsed}
hideThemeButton={!context.active}
hideHeaderTitle
headerActions={context.headerActions}
headerPortalTarget={context.headerPortalTarget}
headerActive={context.active}
composerPortalTarget={context.composerPortalTarget}
composerActive={context.active}
composerInputAriaLabel={t("workbench.composerAria", {
defaultValue: "Message {{title}}",
title: pane.title,
})}
workspaceScope={paneScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null}
workspaceScopeDisabled={paneRunning}
workspaceError={context.active ? workspaceError : null}
onWorkspaceScopeChange={(scope) => {
if (paneRunning) return;
const next = normalizeWorkspaceScope(scope);
setWorkspaceError(null);
setWorkspaceOverrides((current) => ({
...current,
[paneSession.chatId]: next,
}));
client.setWorkspaceScope(paneSession.chatId, next);
}}
settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
skills={skills}
/>
);
}}
/>
</div>
{view !== "chat" && (
@@ -2398,7 +2718,8 @@ function Shell({
<Suspense fallback={null}>
<DeleteConfirm
open
title={pendingDelete.label}
title={pendingDelete.items[0]?.label ?? ""}
count={pendingDelete.items.length}
automations={pendingDelete.automations}
onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete}
+740 -34
View File
@@ -1,22 +1,33 @@
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type DragEvent,
type RefObject,
} from "react";
import {
Archive,
ArchiveRestore,
BringToFront,
CornerDownRight,
Folder,
ListChecks,
MessageCircleDashed,
MoreHorizontal,
PanelsTopLeft,
Pencil,
Pin,
PinOff,
Plus,
Square,
SquareCheckBig,
SquareMinus,
Trash2,
Unplug,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -25,6 +36,9 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@@ -43,7 +57,13 @@ import {
visibleSessionsForGroup,
type ChatGroupLabels,
} from "@/lib/chat-groups";
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
import {
clearDraggedSession,
writeDraggedPane,
writeDraggedSession,
type DraggedPane,
} from "@/lib/session-drag";
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
@@ -52,6 +72,21 @@ const INITIAL_VISIBLE_SESSIONS = 160;
const VISIBLE_SESSIONS_INCREMENT = 160;
const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
export interface SidebarPaneGroup {
topicKey: string;
activePaneKey: string;
panes: Array<{
key: string;
chatId: string;
title: string;
}>;
}
export interface SidebarDeleteItem {
key: string;
label: string;
}
interface ChatListProps {
sessions: ChatSummary[];
temporarySessions?: ChatSummary[];
@@ -59,9 +94,17 @@ interface ChatListProps {
onSelect: (key: string) => void;
onCloseTemporaryChat?: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
paneGroups?: Record<string, SidebarPaneGroup>;
onSelectPane?: (tabKey: string, paneKey: string) => void;
onDetachPane?: (tabKey: string, paneKey: string) => void;
onPromotePane?: (tabKey: string, paneKey: string) => void;
attachableTabKeys?: string[];
paneAcceptingTabKeys?: string[];
onAttachPane?: (paneKey: string, tabKey: string) => void;
onReorderSessions?: (keys: string[]) => void;
onToggleGroup?: (groupId: string) => void;
onRequestRenameProject?: (projectKey: string, label: string) => void;
@@ -92,9 +135,17 @@ export const ChatList = memo(function ChatList({
onSelect,
onCloseTemporaryChat,
onRequestDelete,
onRequestDeleteMany,
onTogglePin,
onRequestRename,
onToggleArchive,
paneGroups = {},
onSelectPane,
onDetachPane,
onPromotePane,
attachableTabKeys = [],
paneAcceptingTabKeys = [],
onAttachPane,
onReorderSessions,
onToggleGroup,
onRequestRenameProject,
@@ -124,7 +175,49 @@ export const ChatList = memo(function ChatList({
edge: "before" | "after";
key: string;
} | null>(null);
const [draggedSessionHeight, setDraggedSessionHeight] = useState(0);
const [draggedPane, setDraggedPane] = useState<DraggedPane | null>(null);
const [tabAttachTargetKey, setTabAttachTargetKey] = useState<string | null>(null);
const tabAttachTargetRef = useRef<string | null>(null);
const tabRowRefs = useRef(new Map<string, HTMLLIElement>());
const pendingTabRectsRef = useRef<Map<string, DOMRect> | null>(null);
const tabLayoutAnimationsRef = useRef(new Map<string, Animation>());
const [deleteSelectionMode, setDeleteSelectionMode] = useState(false);
const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>(
() => new Set(),
);
const activeRowRef = useRef<HTMLDivElement>(null);
const selectedPaneGroup = activeKey ? paneGroups[activeKey] : undefined;
const selectedRowKey = selectedPaneGroup
? selectedPaneGroup.activePaneKey
: activeKey;
const attachableTabs = useMemo(() => new Set(attachableTabKeys), [attachableTabKeys]);
const paneAcceptingTabs = useMemo(
() => new Set(paneAcceptingTabKeys),
[paneAcceptingTabKeys],
);
const deleteItemsByKey = useMemo(() => {
const items = new Map<string, SidebarDeleteItem>();
for (const group of Object.values(paneGroups)) {
for (const pane of group.panes) {
items.set(pane.key, { key: pane.key, label: pane.title });
}
}
for (const session of sessions) {
if (items.has(session.key)) continue;
items.set(session.key, {
key: session.key,
label: displayTitle(session, titleOverrides, t("chat.newChat")),
});
}
return items;
}, [paneGroups, sessions, t, titleOverrides]);
const paneMoveTargets = useMemo(() => sessions
.filter((session) => paneAcceptingTabs.has(session.key))
.map((session) => ({
key: session.key,
title: deleteItemsByKey.get(session.key)?.label ?? session.title ?? session.chatId,
})), [deleteItemsByKey, paneAcceptingTabs, sessions]);
const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
@@ -196,6 +289,80 @@ export const ChatList = memo(function ChatList({
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]);
useEffect(() => {
if (!deleteSelectionMode) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setDeleteSelectionMode(false);
setSelectedDeleteKeys(new Set());
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [deleteSelectionMode]);
const measureTabRows = useCallback(() => {
const rects = new Map<string, DOMRect>();
for (const [key, row] of tabRowRefs.current) {
rects.set(key, row.getBoundingClientRect());
}
return rects;
}, []);
const updateTabAttachTarget = useCallback((next: string | null) => {
if (tabAttachTargetRef.current === next) return;
for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel();
tabLayoutAnimationsRef.current.clear();
pendingTabRectsRef.current = measureTabRows();
tabAttachTargetRef.current = next;
setTabAttachTargetKey(next);
}, [measureTabRows]);
const resetDragState = useCallback(() => {
clearDraggedSession();
setDraggedSessionKey(null);
setDraggedPane(null);
setSessionDropTarget(null);
updateTabAttachTarget(null);
setDraggedSessionHeight(0);
}, [updateTabAttachTarget]);
useLayoutEffect(() => {
const previousRects = pendingTabRectsRef.current;
if (!previousRects) return;
pendingTabRectsRef.current = null;
const nextRects = measureTabRows();
const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) return;
for (const [key, nextRect] of nextRects) {
const previousRect = previousRects.get(key);
const row = tabRowRefs.current.get(key);
if (!previousRect || !row || typeof row.animate !== "function") continue;
const deltaY = previousRect.top - nextRect.top;
if (Math.abs(deltaY) < 0.5) continue;
const animation = row.animate(
[
{ transform: `translateY(${deltaY}px)` },
{ transform: "translateY(0)" },
],
{
duration: 180,
easing: "cubic-bezier(0.2, 0, 0, 1)",
},
);
tabLayoutAnimationsRef.current.set(key, animation);
animation.addEventListener("finish", () => {
if (tabLayoutAnimationsRef.current.get(key) === animation) {
tabLayoutAnimationsRef.current.delete(key);
}
}, { once: true });
}
}, [measureTabRows, tabAttachTargetKey]);
useEffect(() => () => {
for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel();
}, []);
if (loading && sessions.length === 0 && temporarySessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">
@@ -218,10 +385,48 @@ export const ChatList = memo(function ChatList({
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
const canReorderSession = (targetKey: string) => (
!!draggedSessionKey
!deleteSelectionMode
&& !!draggedSessionKey
&& draggedSessionKey !== targetKey
&& sessionLanes.get(draggedSessionKey) === sessionLanes.get(targetKey)
);
const beginDeleteSelection = (keys: string[]) => {
setDeleteSelectionMode(true);
setSelectedDeleteKeys(new Set(keys.filter((key) => deleteItemsByKey.has(key))));
};
const toggleDeleteSelection = (keys: string[]) => {
setSelectedDeleteKeys((current) => {
const next = new Set(current);
const validKeys = keys.filter((key) => deleteItemsByKey.has(key));
const remove = validKeys.length > 0 && validKeys.every((key) => next.has(key));
for (const key of validKeys) {
if (remove) next.delete(key);
else next.add(key);
}
return next;
});
};
const closeDeleteSelection = () => {
setDeleteSelectionMode(false);
setSelectedDeleteKeys(new Set());
};
const requestDeleteItems = (items: SidebarDeleteItem[]) => {
if (items.length === 0) return;
if (onRequestDeleteMany) onRequestDeleteMany(items);
else if (items.length === 1) onRequestDelete(items[0].key, items[0].label);
};
const requestDeleteKeys = (keys: string[]) => {
requestDeleteItems(keys
.map((key) => deleteItemsByKey.get(key))
.filter((item): item is SidebarDeleteItem => item !== undefined));
};
const confirmDeleteSelection = () => {
requestDeleteKeys(Array.from(selectedDeleteKeys));
closeDeleteSelection();
};
const draggedItemTitle = draggedPane
? deleteItemsByKey.get(draggedPane.paneKey)?.label
: draggedSessionKey ? deleteItemsByKey.get(draggedSessionKey)?.label : undefined;
const reorderSession = (targetKey: string, edge: "before" | "after") => {
if (!draggedSessionKey || !canReorderSession(targetKey) || !onReorderSessions) return;
const keys = groups.flatMap((group) => group.sessions.map((session) => session.key));
@@ -240,7 +445,7 @@ export const ChatList = memo(function ChatList({
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<SidebarSelectionHighlight
targetRef={activeRowRef}
activeId={activeKey}
activeId={draggedSessionKey || draggedPane ? null : selectedRowKey}
scope="sessions"
data-chat-list-content
className="relative min-w-0 space-y-3 px-2 py-1.5"
@@ -265,6 +470,12 @@ export const ChatList = memo(function ChatList({
);
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
const reorderOffsets = sessionReorderOffsets(
visibleSessions.map((session) => session.key),
draggedSessionKey,
sessionDropTarget,
draggedSessionHeight,
);
return (
<section key={group.id} aria-label={group.label} className="relative z-[1]">
@@ -298,12 +509,28 @@ export const ChatList = memo(function ChatList({
{group.kind === "project" && collapsedGroups[group.id] ? null : (
<ul className="space-y-0.5">
{visibleSessions.map((s) => {
const active = s.key === activeKey;
const topicActive = s.key === activeKey;
const paneGroup = paneGroups[s.key];
const fallbackTitle = t("chat.fallbackTitle", {
id: s.chatId.slice(0, 6),
});
const generatedTitle = s.title?.trim() || "";
const title = displayTitle(s, titleOverrides, t("chat.newChat"));
const resolvedPaneGroup = paneGroup ?? {
topicKey: s.key,
activePaneKey: s.key,
panes: [{ key: s.key, chatId: s.chatId, title }],
};
const active = topicActive && resolvedPaneGroup.activePaneKey === s.key;
const paneCount = resolvedPaneGroup.panes.length;
const tabDeleteKeys = resolvedPaneGroup.panes.map((pane) => pane.key);
const tabSelected = tabDeleteKeys.every((key) => (
selectedDeleteKeys.has(key)
));
const tabPartiallySelected = !tabSelected && tabDeleteKeys.some((key) => (
selectedDeleteKeys.has(key)
));
const isAttachTarget = tabAttachTargetKey === s.key;
const tooltipTitle =
titleOverrides[s.key]?.trim() ||
generatedTitle ||
@@ -318,24 +545,83 @@ export const ChatList = memo(function ChatList({
const projectMode = group.kind === "project";
const activityState = running.has(s.chatId)
? "running"
: updated.has(s.chatId) && !active
: updated.has(s.chatId) && !topicActive
? "updated"
: null;
return (
<li
key={s.key}
className="relative min-w-0"
ref={(element) => {
if (element) tabRowRefs.current.set(s.key, element);
else tabRowRefs.current.delete(s.key);
}}
data-session-dragging={draggedSessionKey === s.key ? "true" : undefined}
data-session-displaced={reorderOffsets.has(s.key) ? "true" : undefined}
data-tab-attach-target={tabAttachTargetKey === s.key ? "true" : undefined}
className={cn(
"relative min-w-0 rounded-xl transition-[transform,opacity,background-color,box-shadow] duration-200 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none",
draggedSessionKey === s.key && "opacity-0",
isAttachTarget
&& "bg-sidebar-accent/35 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]",
)}
style={{
transform: reorderOffsets.has(s.key)
? `translateY(${reorderOffsets.get(s.key)}px)`
: undefined,
}}
onDragOver={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
const relativeY = rect.height > 0
? (event.clientY - rect.top) / rect.height
: 0.5;
const paneCanAttach = Boolean(
!deleteSelectionMode
&& draggedPane
&& draggedPane.sourceTabKey !== s.key
&& paneAcceptingTabs.has(s.key)
&& onAttachPane,
);
const tabCanAttach = Boolean(
!deleteSelectionMode
&& draggedSessionKey
&& draggedSessionKey !== s.key
&& attachableTabs.has(draggedSessionKey)
&& paneAcceptingTabs.has(s.key)
&& relativeY >= 0.25
&& relativeY <= 0.75
&& onAttachPane,
);
if (paneCanAttach || tabCanAttach) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setSessionDropTarget(null);
updateTabAttachTarget(s.key);
return;
}
updateTabAttachTarget(null);
if (!canReorderSession(s.key)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
const rect = event.currentTarget.getBoundingClientRect();
setSessionDropTarget({
const nextTarget = {
key: s.key,
edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after",
});
} as const;
setSessionDropTarget((current) => (
current?.key === nextTarget.key && current.edge === nextTarget.edge
? current
: nextTarget
));
}}
onDrop={(event) => {
if (tabAttachTargetKey === s.key && onAttachPane) {
const paneKey = draggedPane?.paneKey ?? draggedSessionKey;
if (paneKey) {
event.preventDefault();
onAttachPane(paneKey, s.key);
}
resetDragState();
return;
}
if (!canReorderSession(s.key)) return;
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
@@ -343,23 +629,13 @@ export const ChatList = memo(function ChatList({
? "before"
: "after";
reorderSession(s.key, edge);
setDraggedSessionKey(null);
setSessionDropTarget(null);
resetDragState();
}}
>
{sessionDropTarget?.key === s.key ? (
<span
aria-hidden
data-session-drop-edge={sessionDropTarget.edge}
className={cn(
"pointer-events-none absolute inset-x-2 z-20 h-0.5 rounded-full bg-primary",
sessionDropTarget.edge === "before" ? "-top-px" : "-bottom-px",
)}
/>
) : null}
<div
ref={active ? activeRowRef : undefined}
data-chat-row={s.key}
data-sidebar-tab={s.key}
className={cn(
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
SIDEBAR_SELECTION_ITEM_CLASS,
@@ -367,36 +643,75 @@ export const ChatList = memo(function ChatList({
active
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
isAttachTarget
&& "bg-sidebar-accent/65 text-sidebar-accent-foreground",
deleteSelectionMode && (tabSelected || tabPartiallySelected)
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
)}
>
<button
type="button"
onClick={() => onSelect(s.key)}
draggable
onClick={() => {
if (deleteSelectionMode) {
toggleDeleteSelection(tabDeleteKeys);
return;
}
if (topicActive && paneGroup && onSelectPane) {
onSelectPane(s.key, s.key);
return;
}
onSelect(s.key);
}}
draggable={!deleteSelectionMode}
onDragStart={(event) => {
setDraggedSessionKey(s.key);
setDraggedPane(null);
setSessionDropTarget(null);
updateTabAttachTarget(null);
setDraggedSessionHeight(
event.currentTarget.closest("li")?.getBoundingClientRect().height
?? event.currentTarget.getBoundingClientRect().height,
);
writeDraggedSession(event.dataTransfer, s.key);
}}
onDragEnd={() => {
clearDraggedSession();
setDraggedSessionKey(null);
setSessionDropTarget(null);
}}
onDragEnd={resetDragState}
aria-current={active ? "page" : undefined}
aria-pressed={deleteSelectionMode ? tabSelected : undefined}
title={tooltipTitle}
className={cn(
"min-w-0 flex-1 overflow-hidden text-left",
"cursor-grab active:cursor-grabbing",
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left",
deleteSelectionMode
? "cursor-default"
: "cursor-grab active:cursor-grabbing",
compact ? "py-1" : "py-1.5",
projectMode && "pl-7",
)}
>
{deleteSelectionMode ? (
<SelectionIndicator
checked={tabSelected}
partial={tabPartiallySelected}
/>
) : paneCount > 1 || isAttachTarget ? (
<PanelsTopLeft
aria-hidden
className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60"
/>
) : null}
<span className="min-w-0 flex-1 overflow-hidden">
{projectMode ? (
<span className="flex w-full min-w-0 items-baseline gap-2">
<span className="min-w-0 flex-1 truncate font-medium leading-5">
{title}
</span>
{paneCount > 1 ? (
<span
aria-hidden
className="shrink-0 text-[10.5px] tabular-nums text-muted-foreground/55"
>
{paneCount}/{MAX_WORKBENCH_PANES}
</span>
) : null}
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
{timestamp ? (
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
@@ -409,6 +724,14 @@ export const ChatList = memo(function ChatList({
<span className="min-w-0 flex-1 truncate font-medium leading-5">
{title}
</span>
{paneCount > 1 ? (
<span
aria-hidden
className="shrink-0 text-[10.5px] tabular-nums text-muted-foreground/55"
>
{paneCount}/{MAX_WORKBENCH_PANES}
</span>
) : null}
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
</span>
)}
@@ -422,15 +745,16 @@ export const ChatList = memo(function ChatList({
{timestamp}
</span>
) : null}
</span>
</button>
<SessionActivityIndicator state={activityState} />
<DropdownMenu modal={false}>
{!deleteSelectionMode ? <DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
topicActive && "opacity-100",
)}
aria-label={t("chat.actions", { title })}
>
@@ -442,6 +766,17 @@ export const ChatList = memo(function ChatList({
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
{paneGroup
&& paneGroup.panes.findIndex((pane) => pane.key === s.key) > 0
&& onPromotePane ? (
<DropdownMenuItem onSelect={() => onPromotePane(s.key, s.key)}>
<BringToFront className="h-4 w-4 shrink-0" />
{t("workbench.promotePane", {
defaultValue: "Make {{title}} the primary pane",
title,
})}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onSelect={() => onTogglePin(s.key)}
>
@@ -468,18 +803,71 @@ export const ChatList = memo(function ChatList({
)}
{isArchived ? t("chat.unarchive") : t("chat.archive")}
</DropdownMenuItem>
{attachableTabs.has(s.key) && onAttachPane ? (
<MoveToTabSubmenu
targets={paneMoveTargets.filter((target) => target.key !== s.key)}
onMove={(targetKey) => onAttachPane(s.key, targetKey)}
/>
) : null}
<DropdownMenuItem
onSelect={() => beginDeleteSelection(tabDeleteKeys)}
>
<ListChecks className="h-4 w-4 shrink-0" />
{t("chat.select", { defaultValue: "Select" })}
</DropdownMenuItem>
<DropdownMenuItem
tone="destructive"
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
window.setTimeout(() => requestDeleteKeys(tabDeleteKeys), 0);
}}
>
<Trash2 className="h-4 w-4 shrink-0" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenu> : null}
</div>
{paneCount > 1 || isAttachTarget ? (
<ActivePaneRows
group={resolvedPaneGroup}
tabTitle={title}
tabActive={topicActive}
activeRowRef={activeRowRef}
running={running}
updated={updated}
onSelectPane={onSelectPane}
onRequestDelete={onRequestDelete}
onRequestRename={onRequestRename}
onDetachPane={onDetachPane}
onPromotePane={onPromotePane}
moveTargets={paneMoveTargets.filter((target) => (
target.key !== resolvedPaneGroup.topicKey
))}
onAttachPane={onAttachPane}
deleteSelectionMode={deleteSelectionMode}
selectedDeleteKeys={selectedDeleteKeys}
onToggleDeleteSelection={toggleDeleteSelection}
onBeginDeleteSelection={beginDeleteSelection}
dropPreview={isAttachTarget && draggedItemTitle ? {
paneTitle: draggedItemTitle,
targetTitle: title,
} : null}
draggedPaneKey={draggedPane?.paneKey ?? null}
onPaneDragStart={(event, pane) => {
setDraggedPane(pane);
setDraggedSessionKey(null);
setSessionDropTarget(null);
updateTabAttachTarget(null);
setDraggedSessionHeight(
event.currentTarget.closest("li")?.getBoundingClientRect().height
?? event.currentTarget.getBoundingClientRect().height,
);
writeDraggedPane(event.dataTransfer, pane);
}}
onPaneDragEnd={resetDragState}
actionMenuPortalContainer={actionMenuPortalContainer}
/>
) : null}
</li>
);
})}
@@ -510,11 +898,329 @@ export const ChatList = memo(function ChatList({
</button>
</div>
) : null}
{deleteSelectionMode ? (
<div
data-testid="delete-selection-bar"
className="sticky bottom-2 z-30 mx-1 mt-3 flex min-h-11 items-center gap-2 rounded-2xl border border-sidebar-border/80 bg-popover/95 p-1.5 pl-2 shadow-[0_10px_30px_rgba(15,23,42,0.14)] backdrop-blur-xl"
>
<button
type="button"
onClick={closeDeleteSelection}
aria-label={t("chat.cancelSelection", {
defaultValue: "Cancel selection",
})}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<X className="h-4 w-4" aria-hidden />
</button>
<span className="min-w-0 flex-1 truncate px-1 text-[12.5px] font-medium text-foreground/85">
{t("chat.selectedCount", {
defaultValue: "{{count}} selected",
count: selectedDeleteKeys.size,
})}
</span>
<button
type="button"
disabled={selectedDeleteKeys.size === 0}
onClick={confirmDeleteSelection}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full bg-destructive px-3 text-[12px] font-semibold text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:pointer-events-none disabled:opacity-40"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
{t("chat.deleteSelected", { defaultValue: "Delete" })}
</button>
</div>
) : null}
</SidebarSelectionHighlight>
</div>
);
});
function sessionReorderOffsets(
keys: string[],
draggedKey: string | null,
target: { edge: "before" | "after"; key: string } | null,
draggedHeight: number,
): Map<string, number> {
const offsets = new Map<string, number>();
if (!draggedKey || !target || draggedHeight <= 0) return offsets;
const sourceIndex = keys.indexOf(draggedKey);
if (sourceIndex < 0 || target.key === draggedKey) return offsets;
const remaining = keys.filter((key) => key !== draggedKey);
const targetIndex = remaining.indexOf(target.key);
if (targetIndex < 0) return offsets;
const finalIndex = targetIndex + (target.edge === "after" ? 1 : 0);
if (sourceIndex < finalIndex) {
for (let index = sourceIndex + 1; index <= finalIndex; index += 1) {
offsets.set(keys[index], -draggedHeight);
}
} else if (sourceIndex > finalIndex) {
for (let index = finalIndex; index < sourceIndex; index += 1) {
offsets.set(keys[index], draggedHeight);
}
}
return offsets;
}
function ActivePaneRows({
group,
tabTitle,
tabActive,
activeRowRef,
running,
updated,
onSelectPane,
onRequestDelete,
onRequestRename,
onDetachPane,
onPromotePane,
moveTargets,
onAttachPane,
deleteSelectionMode,
selectedDeleteKeys,
onToggleDeleteSelection,
onBeginDeleteSelection,
dropPreview,
draggedPaneKey,
onPaneDragStart,
onPaneDragEnd,
actionMenuPortalContainer,
}: {
group: SidebarPaneGroup;
tabTitle: string;
tabActive: boolean;
activeRowRef: RefObject<HTMLDivElement>;
running: ReadonlySet<string>;
updated: ReadonlySet<string>;
onSelectPane?: (tabKey: string, paneKey: string) => void;
onRequestDelete: (key: string, label: string) => void;
onRequestRename: (key: string, label: string) => void;
onDetachPane?: (tabKey: string, paneKey: string) => void;
onPromotePane?: (tabKey: string, paneKey: string) => void;
moveTargets: Array<{ key: string; title: string }>;
onAttachPane?: (paneKey: string, tabKey: string) => void;
deleteSelectionMode: boolean;
selectedDeleteKeys: ReadonlySet<string>;
onToggleDeleteSelection: (keys: string[]) => void;
onBeginDeleteSelection: (keys: string[]) => void;
dropPreview: { paneTitle: string; targetTitle: string } | null;
draggedPaneKey: string | null;
onPaneDragStart: (event: DragEvent<HTMLButtonElement>, pane: DraggedPane) => void;
onPaneDragEnd: () => void;
actionMenuPortalContainer?: HTMLElement | null;
}) {
const { t } = useTranslation();
const childPanes = group.panes.filter((pane) => pane.key !== group.topicKey);
return (
<ul
aria-label={t("workbench.panesInTab", {
defaultValue: "Panes in {{title}}",
title: tabTitle,
})}
className={cn(
"relative ml-5 mr-1 mt-0.5 space-y-0.5 rounded-bl-lg border-l border-sidebar-border/60 py-0.5 pl-2 pr-0.5",
dropPreview && "pb-1",
)}
>
{childPanes.map((pane) => {
const index = group.panes.findIndex((candidate) => candidate.key === pane.key);
const active = tabActive && pane.key === group.activePaneKey;
const activityState = running.has(pane.chatId)
? "running"
: updated.has(pane.chatId) && !active
? "updated"
: null;
const paneActionsLabel = t("workbench.paneActions", {
defaultValue: "{{title}} pane actions",
title: pane.title,
});
const selected = selectedDeleteKeys.has(pane.key);
return (
<li
key={pane.key}
data-pane-dragging={draggedPaneKey === pane.key ? "true" : undefined}
className="relative min-w-0 before:absolute before:-left-2 before:top-1/2 before:h-px before:w-2 before:bg-sidebar-border/45"
>
<div
ref={active ? activeRowRef : undefined}
data-chat-row={pane.key}
data-sidebar-pane={pane.key}
className={cn(
"group/pane flex min-h-7 min-w-0 items-center gap-1 rounded-lg px-2 text-[12.5px]",
SIDEBAR_SELECTION_ITEM_CLASS,
active
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/72 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
deleteSelectionMode && selected
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
)}
>
<button
type="button"
onClick={() => {
if (deleteSelectionMode) {
onToggleDeleteSelection([pane.key]);
return;
}
onSelectPane?.(group.topicKey, pane.key);
}}
draggable={!deleteSelectionMode}
onDragStart={(event) => onPaneDragStart(event, {
paneKey: pane.key,
sourceTabKey: group.topicKey,
})}
onDragEnd={onPaneDragEnd}
aria-current={active ? "true" : undefined}
aria-pressed={deleteSelectionMode ? selected : undefined}
title={pane.title}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 py-1 text-left font-medium leading-5",
deleteSelectionMode ? "cursor-default" : "cursor-grab active:cursor-grabbing",
)}
>
{deleteSelectionMode ? (
<SelectionIndicator checked={selected} partial={false} />
) : null}
<span className="min-w-0 flex-1 truncate">{pane.title}</span>
</button>
<SessionActivityIndicator state={activityState} />
{!deleteSelectionMode ? <DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover/pane:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={paneActionsLabel}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={ACTION_MENU_CONTENT_CLASS}
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
{index > 0 && onPromotePane ? (
<DropdownMenuItem onSelect={() => onPromotePane(group.topicKey, pane.key)}>
<BringToFront className="h-4 w-4 shrink-0" />
{t("workbench.promotePane", {
defaultValue: "Make {{title}} the primary pane",
title: pane.title,
})}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onSelect={() => onRequestRename(pane.key, pane.title)}
>
<Pencil className="h-4 w-4 shrink-0" />
{t("chat.rename")}
</DropdownMenuItem>
{onDetachPane ? (
<DropdownMenuItem onSelect={() => onDetachPane(group.topicKey, pane.key)}>
<Unplug className="h-4 w-4 shrink-0" />
{t("workbench.detachPane", {
defaultValue: "Move {{title}} to its own topic",
title: pane.title,
})}
</DropdownMenuItem>
) : null}
{onAttachPane ? (
<MoveToTabSubmenu
targets={moveTargets}
onMove={(targetKey) => onAttachPane(pane.key, targetKey)}
/>
) : null}
<DropdownMenuItem
onSelect={() => onBeginDeleteSelection([pane.key])}
>
<ListChecks className="h-4 w-4 shrink-0" />
{t("chat.select", { defaultValue: "Select" })}
</DropdownMenuItem>
<DropdownMenuItem
tone="destructive"
onSelect={() => {
window.setTimeout(() => onRequestDelete(pane.key, pane.title), 0);
}}
>
<Trash2 className="h-4 w-4 shrink-0" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu> : null}
</div>
</li>
);
})}
{dropPreview ? (
<li
data-pane-drop-preview
role="status"
aria-label={t("workbench.dropPane", {
defaultValue: "Move {{pane}} into {{tab}}",
pane: dropPreview.paneTitle,
tab: dropPreview.targetTitle,
})}
className="relative min-w-0 before:absolute before:-left-2 before:top-1/2 before:h-px before:w-2 before:bg-primary/45"
>
<div className="flex min-h-7 items-center gap-2 rounded-lg border border-primary/30 bg-primary/[0.07] px-2 text-[12.5px] font-medium text-foreground/80 shadow-[inset_0_0_0_1px_hsl(var(--background)/0.5)] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-150">
<CornerDownRight className="h-3.5 w-3.5 shrink-0 text-primary/75" aria-hidden />
<span className="min-w-0 flex-1 truncate">{dropPreview.paneTitle}</span>
</div>
</li>
) : null}
</ul>
);
}
function SelectionIndicator({
checked,
partial,
}: {
checked: boolean;
partial: boolean;
}) {
const Icon = partial ? SquareMinus : checked ? SquareCheckBig : Square;
return (
<Icon
aria-hidden
className={cn(
"h-4 w-4 shrink-0",
checked || partial ? "text-primary" : "text-muted-foreground/55",
)}
/>
);
}
function MoveToTabSubmenu({
targets,
onMove,
}: {
targets: Array<{ key: string; title: string }>;
onMove: (targetKey: string) => void;
}) {
const { t } = useTranslation();
if (targets.length === 0) return null;
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PanelsTopLeft className="h-4 w-4 shrink-0" aria-hidden />
{t("workbench.moveToTab", { defaultValue: "Move to tab" })}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{targets.map((target) => (
<DropdownMenuItem key={target.key} onSelect={() => onMove(target.key)}>
<span className="max-w-56 truncate">{target.title}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
function TemporaryChatSection({
sessions,
activeKey,
+19 -3
View File
@@ -18,6 +18,7 @@ import type { SessionAutomationJob } from "@/lib/types";
interface DeleteConfirmProps {
open: boolean;
title: string;
count?: number;
automations?: SessionAutomationJob[];
onCancel: () => void;
onConfirm: () => void;
@@ -26,6 +27,7 @@ interface DeleteConfirmProps {
export function DeleteConfirm({
open,
title,
count = 1,
automations = [],
onCancel,
onConfirm,
@@ -33,6 +35,7 @@ export function DeleteConfirm({
const { t } = useTranslation();
const locale = currentLocale();
const hasAutomations = automations.length > 0;
const multiple = count > 1;
const visibleAutomations = automations.slice(0, 4);
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
return (
@@ -47,12 +50,25 @@ export function DeleteConfirm({
</div>
</div>
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
{t("deleteConfirm.title", { title })}
{multiple
? t("deleteConfirm.titleMany", {
defaultValue: "Delete {{count}} topics and panes?",
count,
})
: t("deleteConfirm.title", { title })}
</AlertDialogTitle>
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
{hasAutomations
? t("deleteConfirm.automationsDescription")
: t("deleteConfirm.description")}
? multiple
? t("deleteConfirm.automationsDescriptionMany", {
defaultValue: "Linked automations will also be deleted.",
})
: t("deleteConfirm.automationsDescription")
: multiple
? t("deleteConfirm.descriptionMany", {
defaultValue: "This action cannot be undone.",
})
: t("deleteConfirm.description")}
</AlertDialogDescription>
{hasAutomations ? (
<div className="mt-4 max-h-40 w-full overflow-y-auto rounded-2xl bg-muted/55 px-3 py-2 text-left">
+21 -1
View File
@@ -16,7 +16,11 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import {
ChatList,
type SidebarDeleteItem,
type SidebarPaneGroup,
} from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import {
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
@@ -39,9 +43,17 @@ interface SidebarProps {
onSelect: (key: string) => void;
onCloseTemporaryChat?: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
paneGroups?: Record<string, SidebarPaneGroup>;
onSelectPane?: (tabKey: string, paneKey: string) => void;
onDetachPane?: (tabKey: string, paneKey: string) => void;
onPromotePane?: (tabKey: string, paneKey: string) => void;
attachableTabKeys?: string[];
paneAcceptingTabKeys?: string[];
onAttachPane?: (paneKey: string, tabKey: string) => void;
onReorderSessions: (keys: string[]) => void;
onToggleGroup: (groupId: string) => void;
onRequestRenameProject: (projectKey: string, label: string) => void;
@@ -230,9 +242,17 @@ export function Sidebar(props: SidebarProps) {
onSelect={props.onSelect}
onCloseTemporaryChat={props.onCloseTemporaryChat}
onRequestDelete={props.onRequestDelete}
onRequestDeleteMany={props.onRequestDeleteMany}
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive}
paneGroups={props.paneGroups}
onSelectPane={props.onSelectPane}
onDetachPane={props.onDetachPane}
onPromotePane={props.onPromotePane}
attachableTabKeys={props.attachableTabKeys}
paneAcceptingTabKeys={props.paneAcceptingTabKeys}
onAttachPane={props.onAttachPane}
onReorderSessions={props.onReorderSessions}
onToggleGroup={props.onToggleGroup}
onRequestRenameProject={props.onRequestRenameProject}
@@ -186,6 +186,7 @@ interface ThreadComposerProps {
) => boolean | void | Promise<boolean | void>;
disabled?: boolean;
placeholder?: string;
inputAriaLabel?: string;
isStreaming?: boolean;
modelLabel?: string | null;
modelDetail?: string | null;
@@ -940,6 +941,7 @@ export function ThreadComposer({
onSend,
disabled,
placeholder,
inputAriaLabel,
isStreaming = false,
modelLabel = null,
modelDetail = null,
@@ -2400,7 +2402,7 @@ export function ThreadComposer({
rows={1}
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
disabled={interactionDisabled}
aria-label={t("thread.composer.inputAria")}
aria-label={inputAriaLabel ?? t("thread.composer.inputAria")}
className={cn(
inputTextClasses,
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
+22 -13
View File
@@ -17,8 +17,11 @@ interface ThreadHeaderProps {
theme: "light" | "dark";
onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean;
hideSidebarToggle?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
hideTitle?: boolean;
actions?: ReactNode;
minimal?: boolean;
promptNavigatorAction?: ReactNode;
sessionInfoAction?: ReactNode;
@@ -33,8 +36,11 @@ export function ThreadHeader({
theme,
onToggleTheme,
hideSidebarToggleForHostChrome = false,
hideSidebarToggle = false,
hostChromeTitleInset = false,
hideThemeButton = false,
hideTitle = false,
actions,
minimal = false,
promptNavigatorAction,
sessionInfoAction,
@@ -54,19 +60,21 @@ export function ThreadHeader({
)}
>
<div className="relative flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />
</Button>
{!minimal ? (
{!hideSidebarToggle ? (
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />
</Button>
) : null}
{!minimal && !hideTitle ? (
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</div>
@@ -76,6 +84,7 @@ export function ThreadHeader({
<div className="ml-auto flex shrink-0 items-center gap-1">
{sessionInfoAction}
{promptNavigatorAction}
{actions}
{onTemporaryChatEnabledChange ? (
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
<Tooltip>
+58 -21
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
@@ -311,9 +312,17 @@ interface ThreadShellProps {
theme?: "light" | "dark";
onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean;
hideSidebarToggle?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
hideHeaderTitle?: boolean;
hideHeader?: boolean;
headerActions?: ReactNode;
headerPortalTarget?: HTMLElement | null;
headerActive?: boolean;
composerPortalTarget?: HTMLElement | null;
composerActive?: boolean;
composerInputAriaLabel?: string;
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null;
@@ -598,9 +607,17 @@ export function ThreadShell({
theme = "light",
onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false,
hideSidebarToggle = false,
hostChromeTitleInset = false,
hideThemeButton = false,
hideHeaderTitle = false,
hideHeader = false,
headerActions,
headerPortalTarget,
headerActive = true,
composerPortalTarget,
composerActive = true,
composerInputAriaLabel,
workspaceScope = null,
workspaceDefaultScope = null,
workspaceControls = null,
@@ -1405,6 +1422,7 @@ export function ThreadShell({
<ThreadComposer
onSend={handleThreadSend}
disabled={!chatId}
inputAriaLabel={composerInputAriaLabel}
isStreaming={turnActive}
placeholder={
showHeroComposer
@@ -1449,6 +1467,7 @@ export function ThreadShell({
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
inputAriaLabel={composerInputAriaLabel}
isStreaming={turnActive}
placeholder={
booting
@@ -1508,28 +1527,33 @@ export function ThreadShell({
/>
) : undefined;
const threadHeader = !hideHeader ? (
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hideSidebarToggle={hideSidebarToggle}
hostChromeTitleInset={hostChromeTitleInset}
hideThemeButton={hideThemeButton}
hideTitle={hideHeaderTitle}
actions={headerActions}
minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction}
temporaryChatEnabled={temporaryChatEnabled}
temporaryChatDisabled={booting || turnActive}
onTemporaryChatEnabledChange={
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
}
/>
) : null;
return (
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
{!hideHeader ? (
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hostChromeTitleInset={hostChromeTitleInset}
hideThemeButton={hideThemeButton}
minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction}
temporaryChatEnabled={temporaryChatEnabled}
temporaryChatDisabled={booting || turnActive}
onTemporaryChatEnabledChange={
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
}
/>
) : null}
{headerPortalTarget === undefined ? threadHeader : null}
<FilePreviewAvailabilityProvider
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
>
@@ -1539,7 +1563,7 @@ export function ThreadShell({
temporary={temporary}
isStreaming={turnActive}
emptyState={emptyState}
composer={composer}
composer={composerPortalTarget === undefined ? composer : null}
activeTurnId={viewportTurnId}
activeTurnStartedHere={activeTurnStartedHere}
conversationKey={historyKey}
@@ -1559,6 +1583,19 @@ export function ThreadShell({
/>
</FilePreviewAvailabilityProvider>
</div>
{headerPortalTarget && headerActive
? createPortal(threadHeader, headerPortalTarget)
: null}
{composerPortalTarget ? createPortal(
<div
hidden={!composerActive}
aria-hidden={!composerActive}
data-testid={composerActive ? "active-pane-composer" : undefined}
>
{composer}
</div>,
composerPortalTarget,
) : null}
{filePreviewPath && historyKey ? (
<FilePreviewPanel
sessionKey={historyKey}
+55 -42
View File
@@ -37,7 +37,7 @@ interface ThreadViewportProps {
messages: UIMessage[];
temporary?: boolean;
isStreaming: boolean;
composer: ReactNode;
composer?: ReactNode;
emptyState?: ReactNode;
scrollToBottomSignal?: number;
activeTurnId?: string | null;
@@ -61,6 +61,7 @@ interface ThreadViewportProps {
const NEAR_BOTTOM_PX = 48;
const NEAR_TOP_PX = 96;
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
export const INITIAL_HISTORY_WINDOW = 160;
@@ -266,11 +267,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
? forkBoundaryMessageCount - hiddenMessageCount
: null;
const hasComposer = composer !== null && composer !== undefined;
const scrollButtonBottom =
keyboardInsetBottom
+ (composerDockHeight > 0
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
: hasComposer
? DEFAULT_SCROLL_BUTTON_BOTTOM_PX
: EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX);
const scrollViewportStyle =
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
@@ -661,7 +665,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<div
ref={contentRef}
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
data-layout={hasMessages ? "thread" : "hero"}
data-layout={hasComposer ? (hasMessages ? "thread" : "hero") : "external"}
className={cn(
"thread-layout mx-auto grid min-h-full w-full",
hasMessages
@@ -699,58 +703,67 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
</div>
) : (
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
<div
className={cn(
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
hasComposer && "sm:items-end sm:pb-8",
)}
>
{emptyState}
</div>
)}
<div
ref={composerDockRef}
data-testid="thread-composer-dock"
onInputCapture={(event) => {
if (event.target instanceof HTMLTextAreaElement) {
composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null;
threadMotionRef.current?.handleComposerInput();
}
}}
onInput={(event) => {
if (!(event.target instanceof HTMLTextAreaElement)) return;
const previousScrollTop = composerInputScrollTopRef.current;
composerInputScrollTopRef.current = null;
const scrollEl = scrollRef.current;
if (scrollEl && previousScrollTop !== null) {
// Textarea autosizing briefly collapses to `height: auto` while
// measuring. Chrome can clamp the sibling thread scrollport in
// that intermediate layout; restore it before paint, then let
// ResizeObserver handle any real final composer height change.
scrollEl.scrollTop = previousScrollTop;
}
}}
className={cn(
"row-start-2 z-10 w-full",
hasMessages ? "relative bg-background" : "relative self-center",
)}
>
{hasComposer ? (
<div
ref={composerDockRef}
data-testid="thread-composer-dock"
onInputCapture={(event) => {
if (event.target instanceof HTMLTextAreaElement) {
composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null;
threadMotionRef.current?.handleComposerInput();
}
}}
onInput={(event) => {
if (!(event.target instanceof HTMLTextAreaElement)) return;
const previousScrollTop = composerInputScrollTopRef.current;
composerInputScrollTopRef.current = null;
const scrollEl = scrollRef.current;
if (scrollEl && previousScrollTop !== null) {
// Textarea autosizing briefly collapses to `height: auto` while
// measuring. Chrome can clamp the sibling thread scrollport in
// that intermediate layout; restore it before paint, then let
// ResizeObserver handle any real final composer height change.
scrollEl.scrollTop = previousScrollTop;
}
}}
className={cn(
hasMessages
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
: "",
"row-start-2 z-10 w-full",
hasMessages ? "relative bg-background" : "relative self-center",
)}
>
<div
data-testid="thread-composer-motion"
className="mx-auto w-full max-w-[58rem]"
className={cn(
hasMessages
? "px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4"
: "",
)}
>
{composer}
<div
data-testid="thread-composer-motion"
className="mx-auto w-full max-w-[58rem]"
>
{composer}
</div>
</div>
</div>
</div>
) : null}
<div
aria-hidden
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
/>
{hasComposer ? (
<div
aria-hidden
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
/>
) : null}
</div>
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
</div>
+40 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Circle } from "lucide-react";
import { ChevronRight, Circle } from "lucide-react";
import {
floatingItemClassName,
@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const menuItemClassName =
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
@@ -115,6 +116,41 @@ const DropdownMenuSeparator = React.forwardRef<
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(menuItemClassName, inset && "pl-8", className)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" aria-hidden />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, sideOffset = 6, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
sideOffset={sideOffset}
className={cn(
floatingSurfaceClassName,
floatingSurfaceMotionClassName,
"max-h-[min(var(--radix-dropdown-menu-content-available-height),22rem)] min-w-[11rem] overflow-y-auto",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
export {
DropdownMenu,
DropdownMenuContent,
@@ -123,5 +159,8 @@ export {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
@@ -0,0 +1,428 @@
import {
Columns2,
Grid2X2,
PanelLeft,
Plus,
Rows2,
Square,
type LucideIcon,
} from "lucide-react";
import {
type CSSProperties,
type FocusEvent,
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils";
export interface WorkbenchPane {
key: string;
reactKey?: string;
title: string;
}
interface PaneRenderContext {
active: boolean;
headerPortalTarget: HTMLElement | null | undefined;
composerPortalTarget: HTMLElement | null | undefined;
headerActions: ReactNode;
}
interface PaneWorkbenchProps {
panes: WorkbenchPane[];
activePaneKey: string;
layout: WorkbenchLayout;
chrome?: boolean;
addPaneDisabled?: boolean;
onActivatePane: (key: string) => void;
onAddPane: () => void;
onLayoutChange: (layout: WorkbenchLayout) => void;
renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode;
}
const LAYOUT_MOTION_DURATION_MS = 260;
const LAYOUT_MOTION_EASING = "cubic-bezier(0.2, 0, 0, 1)";
const LAYOUT_CONTROLS: Array<{
icon: LucideIcon;
layout: WorkbenchLayout;
label: string;
}> = [
{ icon: Columns2, layout: "columns", label: "Columns" },
{ icon: Rows2, layout: "rows", label: "Rows" },
{ icon: Grid2X2, layout: "grid", label: "Grid" },
{ icon: PanelLeft, layout: "main-stack", label: "Main and stack" },
{ icon: Square, layout: "monocle", label: "Monocle" },
];
function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSProperties {
const count = Math.max(1, paneCount);
switch (layout) {
case "columns":
return {
gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))`,
gridTemplateRows: "minmax(0, 1fr)",
};
case "rows":
return {
gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: `repeat(${count}, minmax(0, 1fr))`,
};
case "grid": {
const columns = Math.ceil(Math.sqrt(count));
const rows = Math.ceil(count / columns);
return {
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
};
}
case "main-stack":
return count === 1
? {
gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: "minmax(0, 1fr)",
}
: {
gridTemplateColumns: "minmax(0, 1.65fr) minmax(0, 1fr)",
gridTemplateRows: `repeat(${count - 1}, minmax(0, 1fr))`,
};
case "monocle":
return {
gridTemplateColumns: "minmax(0, 1fr)",
gridTemplateRows: "minmax(0, 1fr)",
};
}
}
function paneCellStyle(
layout: WorkbenchLayout,
paneCount: number,
index: number,
): CSSProperties | undefined {
if (layout !== "main-stack" || paneCount < 2) return undefined;
return index === 0
? { gridColumn: 1, gridRow: `1 / span ${paneCount - 1}` }
: { gridColumn: 2, gridRow: index };
}
function isPaneAction(target: EventTarget | null): boolean {
return target instanceof Element
&& target.closest("[data-workbench-pane-action]") !== null;
}
function HeaderIconButton({
disabled,
icon: Icon,
label,
onClick,
}: {
disabled?: boolean;
icon: LucideIcon;
label: string;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled}
aria-label={label}
onClick={onClick}
className="host-no-drag h-8 w-8 shrink-0 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Icon className="h-4 w-4" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
}
export function PaneWorkbench({
panes,
activePaneKey,
layout,
chrome = true,
addPaneDisabled = false,
onActivatePane,
onAddPane,
onLayoutChange,
renderPane,
}: PaneWorkbenchProps) {
const { t } = useTranslation();
const compact = useMediaQuery("(max-width: 767px)");
const effectiveLayout = compact ? "monocle" : layout;
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null);
const paneRefs = useRef(new Map<string, HTMLElement>());
const lastRectsRef = useRef(new Map<string, DOMRect>());
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
const animationsRef = useRef(new Map<string, Animation>());
const paneOrder = useMemo(() => panes.map((pane) => pane.key).join("\u0000"), [panes]);
const measurePanes = useCallback(() => {
const rects = new Map<string, DOMRect>();
for (const [key, element] of paneRefs.current) {
if (!element.hidden) rects.set(key, element.getBoundingClientRect());
}
return rects;
}, []);
const captureLayout = useCallback(() => {
pendingRectsRef.current = measurePanes();
for (const animation of animationsRef.current.values()) animation.cancel();
animationsRef.current.clear();
}, [measurePanes]);
useLayoutEffect(() => {
const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
pendingRectsRef.current = null;
const nextRects = measurePanes();
const reduceMotion = typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) {
for (const [key, nextRect] of nextRects) {
const previousRect = previousRects.get(key);
const element = paneRefs.current.get(key);
if (!element) continue;
if (!previousRect) {
if (previousRects.size === 0 || typeof element.animate !== "function") continue;
const animation = element.animate(
[
{ opacity: 0, transform: "translateY(5px) scale(0.995)" },
{ opacity: 1, transform: "translateY(0) scale(1)" },
],
{
duration: 180,
easing: LAYOUT_MOTION_EASING,
fill: "backwards",
},
);
animationsRef.current.set(key, animation);
animation.addEventListener("finish", () => {
if (animationsRef.current.get(key) === animation) {
animationsRef.current.delete(key);
}
}, { once: true });
continue;
}
if (previousRect.width === 0 || previousRect.height === 0) continue;
const deltaX = previousRect.left - nextRect.left;
const deltaY = previousRect.top - nextRect.top;
const scaleX = previousRect.width / nextRect.width;
const scaleY = previousRect.height / nextRect.height;
if (
Math.abs(deltaX) < 0.5
&& Math.abs(deltaY) < 0.5
&& Math.abs(scaleX - 1) < 0.002
&& Math.abs(scaleY - 1) < 0.002
) {
continue;
}
if (typeof element.animate !== "function") continue;
const animation = element.animate(
[
{ transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})` },
{ transform: "translate(0, 0) scale(1, 1)" },
],
{
duration: LAYOUT_MOTION_DURATION_MS,
easing: LAYOUT_MOTION_EASING,
},
);
animationsRef.current.set(key, animation);
animation.addEventListener("finish", () => {
if (animationsRef.current.get(key) === animation) {
animationsRef.current.delete(key);
}
}, { once: true });
}
}
lastRectsRef.current = nextRects;
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]);
useEffect(() => () => {
for (const animation of animationsRef.current.values()) animation.cancel();
}, []);
const activatePane = useCallback((key: string, target: EventTarget | null) => {
if (key === activePaneKey || isPaneAction(target)) return;
captureLayout();
onActivatePane(key);
}, [activePaneKey, captureLayout, onActivatePane]);
const handlePanePointerDown = useCallback((
key: string,
event: PointerEvent<HTMLElement>,
) => {
activatePane(key, event.target);
}, [activatePane]);
const handlePaneFocus = useCallback((key: string, event: FocusEvent<HTMLElement>) => {
activatePane(key, event.target);
}, [activatePane]);
const gridStyle = paneGridStyle(effectiveLayout, panes.length);
const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout)
?? LAYOUT_CONTROLS[0];
const headerActions = chrome ? (
<div
data-workbench-pane-action
className="host-no-drag flex items-center gap-0.5"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t("workbench.layout", {
defaultValue: "Pane layout",
})}
className="host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<currentLayout.icon className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuLabel>
{t("workbench.layout", { defaultValue: "Pane layout" })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={layout}
onValueChange={(value) => {
const next = value as WorkbenchLayout;
if (next === layout) return;
captureLayout();
onLayoutChange(next);
}}
>
{LAYOUT_CONTROLS.map((control) => (
<DropdownMenuRadioItem
key={control.layout}
value={control.layout}
>
<control.icon aria-hidden />
{t(`workbench.layouts.${control.layout}`, {
defaultValue: control.label,
})}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<HeaderIconButton
disabled={addPaneDisabled}
icon={Plus}
label={t("workbench.addPane", { defaultValue: "Add pane" })}
onClick={() => {
captureLayout();
onAddPane();
}}
/>
</div>
) : null;
return (
<section
aria-label={t("workbench.aria", { defaultValue: "Conversation workbench" })}
className="flex h-full min-h-0 flex-col overflow-hidden bg-background"
>
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
{chrome ? (
<header className="shrink-0 bg-background">
<div
ref={setHeaderPortalTarget}
data-testid="workbench-header-host"
/>
</header>
) : null}
<div className="min-h-0 flex-1 bg-background">
<div
data-testid="pane-grid"
data-layout={effectiveLayout}
className={cn(
"grid h-full min-h-0 min-w-0 overflow-hidden",
chrome && panes.length > 1 && "gap-px bg-border/55",
)}
style={gridStyle}
>
{panes.map((pane, index) => {
const active = pane.key === activePaneKey;
const hidden = effectiveLayout === "monocle" && !active;
return (
<section
key={pane.reactKey ?? pane.key}
ref={(element) => {
if (element) paneRefs.current.set(pane.key, element);
else paneRefs.current.delete(pane.key);
}}
hidden={hidden}
aria-label={pane.title}
data-active={active ? "true" : "false"}
data-testid={`workbench-pane-${pane.key}`}
onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)}
onFocusCapture={(event) => handlePaneFocus(pane.key, event)}
className="workbench-pane relative flex min-h-0 min-w-0 overflow-hidden bg-background"
style={paneCellStyle(effectiveLayout, panes.length, index)}
>
{renderPane(pane, {
active,
headerPortalTarget: chrome ? headerPortalTarget : undefined,
composerPortalTarget: chrome ? composerPortalTarget : undefined,
headerActions,
})}
</section>
);
})}
</div>
</div>
{chrome ? (
<footer className="shrink-0 bg-background px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
<div
ref={setComposerPortalTarget}
data-testid="workbench-composer-host"
className="mx-auto w-full max-w-[58rem]"
/>
</footer>
) : null}
</TooltipProvider>
</section>
);
}
@@ -0,0 +1,280 @@
export const WORKBENCH_STORAGE_KEY = "nanobot.webui.workbench.v2";
export const MAX_WORKBENCH_PANES = 4;
export const WORKBENCH_LAYOUTS = [
"columns",
"rows",
"grid",
"main-stack",
"monocle",
] as const;
export type WorkbenchLayout = (typeof WORKBENCH_LAYOUTS)[number];
export interface WorkbenchTabState {
paneKeys: string[];
activePaneKey: string;
layout: WorkbenchLayout;
}
export interface WorkbenchState {
version: 2;
tabs: Record<string, WorkbenchTabState>;
}
export const EMPTY_WORKBENCH_STATE: WorkbenchState = {
version: 2,
tabs: {},
};
function isLayout(value: unknown): value is WorkbenchLayout {
return typeof value === "string"
&& (WORKBENCH_LAYOUTS as readonly string[]).includes(value);
}
function uniqueKeys(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return Array.from(new Set(
value.filter((key): key is string => typeof key === "string" && key.length > 0),
));
}
function normalizeTab(value: unknown, tabKey: string): WorkbenchTabState {
const candidate = value && typeof value === "object"
? value as Partial<WorkbenchTabState>
: {};
const paneKeys = uniqueKeys(candidate.paneKeys);
const normalizedPaneKeys = (paneKeys.includes(tabKey)
? paneKeys
: [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES);
return {
paneKeys: normalizedPaneKeys,
activePaneKey:
typeof candidate.activePaneKey === "string"
&& normalizedPaneKeys.includes(candidate.activePaneKey)
? candidate.activePaneKey
: normalizedPaneKeys[0],
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
};
}
export function parseWorkbenchState(serialized: string | null): WorkbenchState {
if (!serialized) return EMPTY_WORKBENCH_STATE;
try {
const parsed = JSON.parse(serialized) as { version?: unknown; tabs?: unknown };
if (
parsed.version !== 2
|| !parsed.tabs
|| typeof parsed.tabs !== "object"
|| Array.isArray(parsed.tabs)
) {
return EMPTY_WORKBENCH_STATE;
}
const tabs = Object.fromEntries(
Object.entries(parsed.tabs).map(([tabKey, tab]) => [tabKey, normalizeTab(tab, tabKey)]),
);
return { version: 2, tabs };
} catch {
return EMPTY_WORKBENCH_STATE;
}
}
export function defaultWorkbenchTab(tabKey: string): WorkbenchTabState {
return {
paneKeys: [tabKey],
activePaneKey: tabKey,
layout: "columns",
};
}
export function workbenchTab(
state: WorkbenchState,
tabKey: string,
): WorkbenchTabState {
return state.tabs[tabKey] ?? defaultWorkbenchTab(tabKey);
}
function updateTab(
state: WorkbenchState,
tabKey: string,
update: (tab: WorkbenchTabState) => WorkbenchTabState,
): WorkbenchState {
const current = workbenchTab(state, tabKey);
const next = update(current);
if (state.tabs[tabKey] === next) return state;
return {
version: 2,
tabs: {
...state.tabs,
[tabKey]: next,
},
};
}
export function ensureWorkbenchTab(
state: WorkbenchState,
tabKey: string,
): WorkbenchState {
if (state.tabs[tabKey]) return state;
return updateTab(state, tabKey, (tab) => tab);
}
export function addWorkbenchPane(
state: WorkbenchState,
tabKey: string,
paneKey: string,
): WorkbenchState {
return updateTab(state, tabKey, (tab) => {
if (tab.paneKeys.includes(paneKey)) {
if (tab.activePaneKey === paneKey) return tab;
return { ...tab, activePaneKey: paneKey };
}
if (tab.paneKeys.length >= MAX_WORKBENCH_PANES) return tab;
return {
...tab,
paneKeys: [...tab.paneKeys, paneKey],
activePaneKey: paneKey,
};
});
}
export function focusWorkbenchPane(
state: WorkbenchState,
tabKey: string,
paneKey: string,
): WorkbenchState {
return updateTab(state, tabKey, (tab) => (
tab.paneKeys.includes(paneKey) && tab.activePaneKey !== paneKey
? { ...tab, activePaneKey: paneKey }
: tab
));
}
export function detachWorkbenchPane(
state: WorkbenchState,
tabKey: string,
paneKey: string,
): WorkbenchState {
return updateTab(state, tabKey, (tab) => {
const index = tab.paneKeys.indexOf(paneKey);
if (index < 0 || paneKey === tabKey || tab.paneKeys.length === 1) return tab;
const paneKeys = tab.paneKeys.filter((key) => key !== paneKey);
const activePaneKey = tab.activePaneKey === paneKey
? paneKeys[Math.min(index, paneKeys.length - 1)]
: tab.activePaneKey;
return { ...tab, paneKeys, activePaneKey };
});
}
export function attachWorkbenchPane(
state: WorkbenchState,
targetTabKey: string,
paneKey: string,
): WorkbenchState {
if (!targetTabKey || !paneKey || targetTabKey === paneKey) return state;
const sourceEntry = Object.entries(state.tabs).find(([, tab]) => (
tab.paneKeys.includes(paneKey)
));
const sourceTabKey = sourceEntry?.[0];
const sourceTab = sourceEntry?.[1];
if (sourceTabKey === targetTabKey) {
return focusWorkbenchPane(state, targetTabKey, paneKey);
}
if (sourceTabKey === paneKey && sourceTab && sourceTab.paneKeys.length > 1) {
return state;
}
const targetBeforeMove = state.tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey);
if (
!targetBeforeMove.paneKeys.includes(paneKey)
&& targetBeforeMove.paneKeys.length >= MAX_WORKBENCH_PANES
) {
return state;
}
const tabs = { ...state.tabs };
if (sourceTabKey && sourceTab) {
if (sourceTabKey === paneKey) {
delete tabs[sourceTabKey];
} else {
const index = sourceTab.paneKeys.indexOf(paneKey);
const paneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey);
tabs[sourceTabKey] = {
...sourceTab,
paneKeys,
activePaneKey: sourceTab.activePaneKey === paneKey
? paneKeys[Math.min(index, paneKeys.length - 1)]
: sourceTab.activePaneKey,
};
}
}
const targetTab = tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey);
tabs[targetTabKey] = targetTab.paneKeys.includes(paneKey)
? { ...targetTab, activePaneKey: paneKey }
: {
...targetTab,
paneKeys: [...targetTab.paneKeys, paneKey],
activePaneKey: paneKey,
};
return { version: 2, tabs };
}
export function promoteWorkbenchPane(
state: WorkbenchState,
tabKey: string,
paneKey: string,
): WorkbenchState {
return updateTab(state, tabKey, (tab) => {
const index = tab.paneKeys.indexOf(paneKey);
if (index <= 0) return tab;
return {
...tab,
paneKeys: [paneKey, ...tab.paneKeys.filter((key) => key !== paneKey)],
};
});
}
export function setWorkbenchLayout(
state: WorkbenchState,
tabKey: string,
layout: WorkbenchLayout,
): WorkbenchState {
return updateTab(state, tabKey, (tab) => (
tab.layout === layout ? tab : { ...tab, layout }
));
}
export function reconcileWorkbench(
state: WorkbenchState,
validKeys: ReadonlySet<string>,
): WorkbenchState {
const tabs: Record<string, WorkbenchTabState> = {};
for (const [tabKey, tab] of Object.entries(state.tabs)) {
if (!validKeys.has(tabKey)) continue;
const paneKeys = tab.paneKeys.filter((key) => validKeys.has(key));
const normalizedPaneKeys = (paneKeys.includes(tabKey)
? paneKeys
: [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES);
tabs[tabKey] = {
...tab,
paneKeys: normalizedPaneKeys,
activePaneKey: normalizedPaneKeys.includes(tab.activePaneKey)
? tab.activePaneKey
: normalizedPaneKeys[0],
};
}
const serializedCurrent = JSON.stringify(state.tabs);
const serializedNext = JSON.stringify(tabs);
return serializedCurrent === serializedNext ? state : { version: 2, tabs };
}
export function workbenchChildPaneKeys(state: WorkbenchState): Set<string> {
const childKeys = new Set<string>();
for (const [tabKey, tab] of Object.entries(state.tabs)) {
for (const paneKey of tab.paneKeys) {
if (paneKey !== tabKey) childKeys.add(paneKey);
}
}
return childKeys;
}
+7
View File
@@ -360,6 +360,9 @@
.thread-layout[data-layout="thread"] {
grid-template-rows: minmax(0, 1fr) auto 0fr;
}
.thread-layout[data-layout="external"] {
grid-template-rows: minmax(0, 1fr);
}
@media (min-width: 640px) {
.thread-layout[data-layout="hero"] {
grid-template-rows: minmax(min-content, 1fr) auto 1fr;
@@ -564,6 +567,10 @@
}
}
.workbench-pane {
transform-origin: top left;
}
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
@keyframes goal-shell-glow-breathe {
0%,
+27
View File
@@ -965,6 +965,10 @@
"unarchive": "Unarchive",
"showArchived": "Show archived",
"hideArchived": "Hide archived",
"select": "Select",
"cancelSelection": "Cancel selection",
"selectedCount": "{{count}} selected",
"deleteSelected": "Delete",
"delete": "Delete",
"newChat": "New topic",
"groups": {
@@ -979,10 +983,13 @@
},
"deleteConfirm": {
"title": "Delete this topic?",
"titleMany": "Delete {{count}} topics and panes?",
"description": "This action cannot be undone.",
"descriptionMany": "This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Delete",
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
"automationsDescriptionMany": "Linked automations will also be deleted.",
"moreAutomations": "+ {{count}} more",
"confirmWithAutomations": "Delete",
"schedule": {
@@ -1378,6 +1385,26 @@
"copy": "Copy",
"copied": "Copied"
},
"workbench": {
"aria": "Conversation workbench",
"panes": "Panes",
"panesInTab": "Panes in {{title}}",
"dropPane": "Move {{pane}} into {{tab}}",
"moveToTab": "Move to tab",
"layout": "Pane layout",
"addPane": "Add pane",
"promotePane": "Make {{title}} the primary pane",
"paneActions": "{{title}} pane actions",
"detachPane": "Move {{title}} to its own topic",
"composerAria": "Message {{title}}",
"layouts": {
"columns": "Columns",
"rows": "Rows",
"grid": "Grid",
"main-stack": "Main and stack",
"monocle": "Monocle"
}
},
"common": {
"dismiss": "Dismiss",
"close": "Close",
+27
View File
@@ -952,6 +952,10 @@
"unarchive": "Desarchivar",
"showArchived": "Mostrar archivados",
"hideArchived": "Ocultar archivados",
"select": "Seleccionar",
"cancelSelection": "Cancelar selección",
"selectedCount": "{{count}} seleccionados",
"deleteSelected": "Eliminar",
"delete": "Eliminar",
"newChat": "Nuevo tema",
"groups": {
@@ -966,10 +970,13 @@
},
"deleteConfirm": {
"title": "¿Eliminar este chat?",
"titleMany": "¿Eliminar {{count}} chats y paneles?",
"description": "Esta acción no se puede deshacer.",
"descriptionMany": "Esta acción no se puede deshacer.",
"cancel": "Cancelar",
"confirm": "Eliminar",
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
"automationsDescriptionMany": "También se eliminarán las automatizaciones vinculadas.",
"moreAutomations": "+ {{count}} más",
"confirmWithAutomations": "Eliminar",
"schedule": {
@@ -1365,6 +1372,26 @@
"copy": "Copiar",
"copied": "Copiado"
},
"workbench": {
"aria": "Área de conversaciones",
"panes": "Paneles",
"panesInTab": "Paneles de {{title}}",
"dropPane": "Mover {{pane}} a {{tab}}",
"moveToTab": "Mover a una pestaña",
"layout": "Diseño de paneles",
"addPane": "Añadir panel",
"promotePane": "Convertir {{title}} en el panel principal",
"paneActions": "Acciones del panel {{title}}",
"detachPane": "Mover {{title}} a su propio tema",
"composerAria": "Mensaje para {{title}}",
"layouts": {
"columns": "Columnas",
"rows": "Filas",
"grid": "Cuadrícula",
"main-stack": "Principal y pila",
"monocle": "Monóculo"
}
},
"common": {
"dismiss": "Cerrar",
"close": "Cerrar",
+27
View File
@@ -951,6 +951,10 @@
"unarchive": "Désarchiver",
"showArchived": "Afficher les archives",
"hideArchived": "Masquer les archives",
"select": "Sélectionner",
"cancelSelection": "Annuler la sélection",
"selectedCount": "{{count}} sélectionnés",
"deleteSelected": "Supprimer",
"delete": "Supprimer",
"newChat": "Nouveau sujet",
"groups": {
@@ -965,10 +969,13 @@
},
"deleteConfirm": {
"title": "Supprimer cette discussion ?",
"titleMany": "Supprimer {{count}} discussions et volets ?",
"description": "Cette action est irréversible.",
"descriptionMany": "Cette action est irréversible.",
"cancel": "Annuler",
"confirm": "Supprimer",
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
"automationsDescriptionMany": "Les automatisations liées seront également supprimées.",
"moreAutomations": "+ {{count}} autres",
"confirmWithAutomations": "Supprimer",
"schedule": {
@@ -1364,6 +1371,26 @@
"copy": "Copier",
"copied": "Copié"
},
"workbench": {
"aria": "Espace de conversations",
"panes": "Volets",
"panesInTab": "Volets dans {{title}}",
"dropPane": "Déplacer {{pane}} dans {{tab}}",
"moveToTab": "Déplacer vers un onglet",
"layout": "Disposition des volets",
"addPane": "Ajouter un volet",
"promotePane": "Définir {{title}} comme volet principal",
"paneActions": "Actions du volet {{title}}",
"detachPane": "Déplacer {{title}} vers son propre sujet",
"composerAria": "Message à {{title}}",
"layouts": {
"columns": "Colonnes",
"rows": "Lignes",
"grid": "Grille",
"main-stack": "Principal et pile",
"monocle": "Monocle"
}
},
"common": {
"dismiss": "Fermer",
"close": "Fermer",
+27
View File
@@ -951,6 +951,10 @@
"unarchive": "Batalkan arsip",
"showArchived": "Tampilkan yang diarsipkan",
"hideArchived": "Sembunyikan yang diarsipkan",
"select": "Pilih",
"cancelSelection": "Batalkan pilihan",
"selectedCount": "{{count}} dipilih",
"deleteSelected": "Hapus",
"delete": "Hapus",
"newChat": "Topik baru",
"groups": {
@@ -965,10 +969,13 @@
},
"deleteConfirm": {
"title": "Hapus obrolan ini?",
"titleMany": "Hapus {{count}} obrolan dan panel?",
"description": "Tindakan ini tidak dapat dibatalkan.",
"descriptionMany": "Tindakan ini tidak dapat dibatalkan.",
"cancel": "Batal",
"confirm": "Hapus",
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
"automationsDescriptionMany": "Automasi terkait juga akan dihapus.",
"moreAutomations": "+ {{count}} lagi",
"confirmWithAutomations": "Hapus",
"schedule": {
@@ -1364,6 +1371,26 @@
"copy": "Salin",
"copied": "Tersalin"
},
"workbench": {
"aria": "Ruang kerja percakapan",
"panes": "Panel",
"panesInTab": "Panel di {{title}}",
"dropPane": "Pindahkan {{pane}} ke {{tab}}",
"moveToTab": "Pindahkan ke tab",
"layout": "Tata letak panel",
"addPane": "Tambah panel",
"promotePane": "Jadikan {{title}} panel utama",
"paneActions": "Tindakan panel {{title}}",
"detachPane": "Pindahkan {{title}} ke topik tersendiri",
"composerAria": "Pesan untuk {{title}}",
"layouts": {
"columns": "Kolom",
"rows": "Baris",
"grid": "Kisi",
"main-stack": "Utama dan tumpukan",
"monocle": "Panel tunggal"
}
},
"common": {
"dismiss": "Tutup",
"close": "Tutup",
+27
View File
@@ -951,6 +951,10 @@
"unarchive": "アーカイブを解除",
"showArchived": "アーカイブ済みを表示",
"hideArchived": "アーカイブ済みを隠す",
"select": "選択",
"cancelSelection": "選択を解除",
"selectedCount": "{{count}} 件を選択中",
"deleteSelected": "削除",
"delete": "削除",
"newChat": "新しいトピック",
"groups": {
@@ -965,10 +969,13 @@
},
"deleteConfirm": {
"title": "このチャットを削除しますか?",
"titleMany": "{{count}} 件のチャットとペインを削除しますか?",
"description": "この操作は元に戻せません。",
"descriptionMany": "この操作は元に戻せません。",
"cancel": "キャンセル",
"confirm": "削除",
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
"automationsDescriptionMany": "関連する自動タスクも削除されます。",
"moreAutomations": "他 {{count}} 件",
"confirmWithAutomations": "削除",
"schedule": {
@@ -1364,6 +1371,26 @@
"copy": "コピー",
"copied": "コピーしました"
},
"workbench": {
"aria": "会話ワークベンチ",
"panes": "ペイン",
"panesInTab": "{{title}} のペイン",
"dropPane": "{{pane}} を {{tab}} に移動",
"moveToTab": "タブへ移動",
"layout": "ペインレイアウト",
"addPane": "ペインを追加",
"promotePane": "{{title}} をメインペインにする",
"paneActions": "{{title}} ペインの操作",
"detachPane": "{{title}} を独立したトピックに移動",
"composerAria": "{{title}} へのメッセージ",
"layouts": {
"columns": "列",
"rows": "行",
"grid": "グリッド",
"main-stack": "メインとスタック",
"monocle": "モノクル"
}
},
"common": {
"dismiss": "閉じる",
"close": "閉じる",
+27
View File
@@ -951,6 +951,10 @@
"unarchive": "보관 해제",
"showArchived": "보관된 항목 표시",
"hideArchived": "보관된 항목 숨기기",
"select": "선택",
"cancelSelection": "선택 취소",
"selectedCount": "{{count}}개 선택됨",
"deleteSelected": "삭제",
"delete": "삭제",
"newChat": "새 주제",
"groups": {
@@ -965,10 +969,13 @@
},
"deleteConfirm": {
"title": "이 채팅을 삭제할까요?",
"titleMany": "채팅과 창 {{count}}개를 삭제할까요?",
"description": "이 작업은 되돌릴 수 없습니다.",
"descriptionMany": "이 작업은 되돌릴 수 없습니다.",
"cancel": "취소",
"confirm": "삭제",
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
"automationsDescriptionMany": "연결된 자동화도 함께 삭제됩니다.",
"moreAutomations": "+ {{count}}개 더",
"confirmWithAutomations": "삭제",
"schedule": {
@@ -1364,6 +1371,26 @@
"copy": "복사",
"copied": "복사됨"
},
"workbench": {
"aria": "대화 워크벤치",
"panes": "창",
"panesInTab": "{{title}}의 창",
"dropPane": "{{pane}}을(를) {{tab}}으로 이동",
"moveToTab": "탭으로 이동",
"layout": "창 레이아웃",
"addPane": "창 추가",
"promotePane": "{{title}}을(를) 기본 창으로 설정",
"paneActions": "{{title}} 창 작업",
"detachPane": "{{title}}을(를) 별도 주제로 이동",
"composerAria": "{{title}}에 메시지 보내기",
"layouts": {
"columns": "열",
"rows": "행",
"grid": "그리드",
"main-stack": "기본 창과 스택",
"monocle": "단일 창"
}
},
"common": {
"dismiss": "닫기",
"close": "닫기",
+27
View File
@@ -965,6 +965,10 @@
"unarchive": "Desarquivar",
"showArchived": "Mostrar arquivadas",
"hideArchived": "Ocultar arquivadas",
"select": "Selecionar",
"cancelSelection": "Cancelar seleção",
"selectedCount": "{{count}} selecionados",
"deleteSelected": "Excluir",
"delete": "Excluir",
"newChat": "Novo tópico",
"groups": {
@@ -979,10 +983,13 @@
},
"deleteConfirm": {
"title": "Excluir esta conversa?",
"titleMany": "Excluir {{count}} conversas e painéis?",
"description": "Esta ação não pode ser desfeita.",
"descriptionMany": "Esta ação não pode ser desfeita.",
"cancel": "Cancelar",
"confirm": "Excluir",
"automationsDescription": "Esta conversa possui automações agendadas. Excluí-la também excluirá essas automações.",
"automationsDescriptionMany": "As automações vinculadas também serão excluídas.",
"moreAutomations": "+ {{count}} a mais",
"confirmWithAutomations": "Excluir",
"schedule": {
@@ -1378,6 +1385,26 @@
"copy": "Copiar",
"copied": "Copiado"
},
"workbench": {
"aria": "Área de conversas",
"panes": "Painéis",
"panesInTab": "Painéis em {{title}}",
"dropPane": "Mover {{pane}} para {{tab}}",
"moveToTab": "Mover para uma aba",
"layout": "Layout de painéis",
"addPane": "Adicionar painel",
"promotePane": "Tornar {{title}} o painel principal",
"paneActions": "Ações do painel {{title}}",
"detachPane": "Mover {{title}} para seu próprio tópico",
"composerAria": "Mensagem para {{title}}",
"layouts": {
"columns": "Colunas",
"rows": "Linhas",
"grid": "Grade",
"main-stack": "Principal e pilha",
"monocle": "Monóculo"
}
},
"common": {
"dismiss": "Descartar",
"close": "Fechar",
+27
View File
@@ -951,6 +951,10 @@
"unarchive": "Bỏ lưu trữ",
"showArchived": "Hiện mục đã lưu trữ",
"hideArchived": "Ẩn mục đã lưu trữ",
"select": "Chọn",
"cancelSelection": "Hủy chọn",
"selectedCount": "Đã chọn {{count}} mục",
"deleteSelected": "Xóa",
"delete": "Xóa",
"newChat": "Chủ đề mới",
"groups": {
@@ -965,10 +969,13 @@
},
"deleteConfirm": {
"title": "Xóa cuộc trò chuyện này?",
"titleMany": "Xóa {{count}} cuộc trò chuyện và khung?",
"description": "Không thể hoàn tác thao tác này.",
"descriptionMany": "Không thể hoàn tác thao tác này.",
"cancel": "Hủy",
"confirm": "Xóa",
"automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
"automationsDescriptionMany": "Các tự động hóa liên kết cũng sẽ bị xóa.",
"moreAutomations": "+ {{count}} mục nữa",
"confirmWithAutomations": "Xóa",
"schedule": {
@@ -1364,6 +1371,26 @@
"copy": "Sao chép",
"copied": "Đã sao chép"
},
"workbench": {
"aria": "Không gian hội thoại",
"panes": "Khung",
"panesInTab": "Các khung trong {{title}}",
"dropPane": "Di chuyển {{pane}} vào {{tab}}",
"moveToTab": "Di chuyển vào thẻ",
"layout": "Bố cục khung",
"addPane": "Thêm khung",
"promotePane": "Đặt {{title}} làm khung chính",
"paneActions": "Thao tác cho khung {{title}}",
"detachPane": "Chuyển {{title}} thành chủ đề riêng",
"composerAria": "Nhắn tin cho {{title}}",
"layouts": {
"columns": "Cột",
"rows": "Hàng",
"grid": "Lưới",
"main-stack": "Khung chính và ngăn xếp",
"monocle": "Một khung"
}
},
"common": {
"dismiss": "Đóng",
"close": "Đóng",
+27
View File
@@ -965,6 +965,10 @@
"unarchive": "取消归档",
"showArchived": "显示归档",
"hideArchived": "隐藏归档",
"select": "选择",
"cancelSelection": "取消选择",
"selectedCount": "已选择 {{count}} 项",
"deleteSelected": "删除",
"delete": "删除",
"newChat": "新建话题",
"groups": {
@@ -979,10 +983,13 @@
},
"deleteConfirm": {
"title": "删除这个话题?",
"titleMany": "删除这 {{count}} 个话题和窗格?",
"description": "此操作无法撤销。",
"descriptionMany": "此操作无法撤销。",
"cancel": "取消",
"confirm": "删除",
"automationsDescription": "这个话题有关联的自动任务。删除话题也会删除这些自动任务。",
"automationsDescriptionMany": "关联的自动任务也会一并删除。",
"moreAutomations": "另有 {{count}} 个",
"confirmWithAutomations": "删除",
"schedule": {
@@ -1378,6 +1385,26 @@
"copy": "复制",
"copied": "已复制"
},
"workbench": {
"aria": "会话工作台",
"panes": "窗格",
"panesInTab": "{{title}} 中的窗格",
"dropPane": "将 {{pane}} 移入 {{tab}}",
"moveToTab": "移动到标签页",
"layout": "窗格布局",
"addPane": "添加窗格",
"promotePane": "将 {{title}} 设为主窗格",
"paneActions": "{{title}} 窗格操作",
"detachPane": "将 {{title}} 移至独立主题",
"composerAria": "向 {{title}} 发送消息",
"layouts": {
"columns": "列布局",
"rows": "行布局",
"grid": "网格",
"main-stack": "主窗格与堆栈",
"monocle": "单窗格"
}
},
"common": {
"dismiss": "关闭",
"close": "关闭",
+27
View File
@@ -951,6 +951,10 @@
"unarchive": "取消封存",
"showArchived": "顯示封存",
"hideArchived": "隱藏封存",
"select": "選取",
"cancelSelection": "取消選取",
"selectedCount": "已選取 {{count}} 項",
"deleteSelected": "刪除",
"delete": "刪除",
"newChat": "新增話題",
"groups": {
@@ -965,10 +969,13 @@
},
"deleteConfirm": {
"title": "刪除這個話題?",
"titleMany": "刪除這 {{count}} 個話題和窗格?",
"description": "此操作無法復原。",
"descriptionMany": "此操作無法復原。",
"cancel": "取消",
"confirm": "刪除",
"automationsDescription": "這個話題含有已排程的自動任務。刪除話題時也會一併刪除這些任務。",
"automationsDescriptionMany": "關聯的自動任務也會一併刪除。",
"moreAutomations": "另有 {{count}} 個",
"confirmWithAutomations": "刪除",
"schedule": {
@@ -1364,6 +1371,26 @@
"copy": "複製",
"copied": "已複製"
},
"workbench": {
"aria": "對話工作台",
"panes": "窗格",
"panesInTab": "{{title}} 中的窗格",
"dropPane": "將 {{pane}} 移入 {{tab}}",
"moveToTab": "移動到分頁",
"layout": "窗格佈局",
"addPane": "新增窗格",
"promotePane": "將 {{title}} 設為主窗格",
"paneActions": "{{title}} 窗格操作",
"detachPane": "將 {{title}} 移至獨立主題",
"composerAria": "傳送訊息給 {{title}}",
"layouts": {
"columns": "欄佈局",
"rows": "列佈局",
"grid": "網格",
"main-stack": "主窗格與堆疊",
"monocle": "單窗格"
}
},
"common": {
"dismiss": "關閉",
"close": "關閉",
+32
View File
@@ -1,6 +1,13 @@
export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key";
export const PANE_DRAG_TYPE = "application/x-nanobot-pane";
export interface DraggedPane {
paneKey: string;
sourceTabKey: string;
}
let activeSessionKey: string | null = null;
let activePane: DraggedPane | null = null;
export function hasDraggedSession(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE);
@@ -13,6 +20,7 @@ export function readDraggedSession(dataTransfer: DataTransfer): string | null {
export function clearDraggedSession(): void {
activeSessionKey = null;
activePane = null;
}
export function writeDraggedSession(
@@ -23,3 +31,27 @@ export function writeDraggedSession(
dataTransfer.effectAllowed = "copyMove";
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
}
export function readDraggedPane(dataTransfer: DataTransfer): DraggedPane | null {
const serialized = dataTransfer.getData(PANE_DRAG_TYPE).trim();
if (serialized) {
try {
const parsed = JSON.parse(serialized) as Partial<DraggedPane>;
if (parsed.paneKey && parsed.sourceTabKey) {
return { paneKey: parsed.paneKey, sourceTabKey: parsed.sourceTabKey };
}
} catch {
// Fall through to the in-memory payload used while the native drag is active.
}
}
return activePane;
}
export function writeDraggedPane(
dataTransfer: DataTransfer,
pane: DraggedPane,
): void {
activePane = pane;
writeDraggedSession(dataTransfer, pane.paneKey);
dataTransfer.setData(PANE_DRAG_TYPE, JSON.stringify(pane));
}
+180 -2
View File
@@ -164,7 +164,24 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
loading: false,
error: null,
refresh: refreshSpy,
createChat: createChatSpy,
createChat: async (scope?: WorkspaceScopePayload | null) => {
const chatId = await createChatSpy(scope);
const now = new Date().toISOString();
setSessions((prev: ChatSummary[]) => [
{
key: `websocket:${chatId}`,
channel: "websocket",
chatId,
createdAt: now,
updatedAt: now,
title: "",
preview: "",
workspaceScope: scope ?? null,
},
...prev.filter((session) => session.chatId !== chatId),
]);
return chatId;
},
forkChat: async () => "fork-chat",
getSessionAutomations: getSessionAutomationsSpy,
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
@@ -290,6 +307,8 @@ describe("App layout", () => {
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
localStorage.removeItem("nanobot-webui.restartStartedAt");
localStorage.removeItem("nanobot-webui.restartRoute");
localStorage.removeItem("nanobot.webui.workbench.v1");
localStorage.removeItem("nanobot.webui.workbench.v2");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
api_token: "api-tok",
@@ -485,8 +504,9 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const firstMessage = "keep this first turn visible";
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
target: { value: "/model" },
target: { value: firstMessage },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
@@ -496,6 +516,7 @@ describe("App layout", () => {
`#/chat/${encodeURIComponent("websocket:chat-1")}`,
),
);
expect(await screen.findByText(firstMessage)).toBeInTheDocument();
});
it("creates a new temporary chat from the hero each time", async () => {
@@ -1649,6 +1670,60 @@ describe("App layout", () => {
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
it("deletes multiple selected topics through one confirmation", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "First chat",
},
{
key: "websocket:chat-b",
channel: "websocket",
chatId: "chat-b",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
preview: "Second chat",
},
{
key: "websocket:chat-c",
channel: "websocket",
chatId: "chat-c",
createdAt: "2026-04-16T12:00:00Z",
updatedAt: "2026-04-16T12:00:00Z",
preview: "Third chat",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.pointerDown(within(sidebar).getByLabelText(
"Topic actions for First chat",
), { button: 0 });
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
fireEvent.click(within(sidebar).getByRole("button", { name: "Second chat" }));
expect(within(sidebar).getByText("2 selected")).toBeInTheDocument();
fireEvent.click(within(sidebar).getByRole("button", { name: "Delete" }));
expect(await screen.findByText("Delete 2 topics and panes?")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleteChatSpy).toHaveBeenCalledTimes(2));
expect(deleteChatSpy.mock.calls.map(([key]) => key)).toEqual([
"websocket:chat-a",
"websocket:chat-b",
]);
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a");
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-b");
expect(within(sidebar).getByRole("button", { name: "Third chat" }))
.toBeInTheDocument();
}, 15_000);
it("shows localized bound automations in the first delete confirmation", async () => {
mockSessions = [
{
@@ -2943,6 +3018,109 @@ describe("App layout", () => {
);
});
it("keeps panes and layout scoped to the current topic tab", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})));
createChatSpy.mockResolvedValueOnce("chat-pane");
mockSessions = [
{
key: "websocket:chat-alpha",
channel: "websocket",
chatId: "chat-alpha",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
title: "Alpha",
preview: "Alpha notes",
},
{
key: "websocket:chat-beta",
channel: "websocket",
chatId: "chat-beta",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
title: "Beta",
preview: "Beta notes",
},
];
window.history.replaceState(
null,
"",
"/#/chat/websocket%3Achat-alpha",
);
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const grid = await screen.findByTestId("pane-grid");
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha"]);
fireEvent.click(screen.getByRole("button", { name: "Add pane" }));
expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument();
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
await waitFor(() => expect(grid.children).toHaveLength(2));
expect(window.location.hash).toBe("#/chat/websocket%3Achat-alpha");
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha", "New topic"]);
const activeComposer = screen.getByTestId("active-pane-composer");
const paneInput = within(activeComposer).getByRole("textbox", {
name: "Message New topic",
});
fireEvent.change(paneInput, { target: { value: "route this to the new pane" } });
fireEvent.keyDown(paneInput, { key: "Enter" });
await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled());
expect(sendMessageSpy.mock.calls.at(-1)?.[0]).toBe("chat-pane");
fireEvent.pointerDown(screen.getByRole("button", { name: "Pane layout" }), {
button: 0,
ctrlKey: false,
});
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
expect(grid).toHaveAttribute("data-layout", "rows");
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const paneTopicButton = within(sidebar)
.getAllByRole("button", { name: "New topic" })
.find((button) => button.closest("[data-sidebar-pane]"));
expect(paneTopicButton).toBeDefined();
expect(paneTopicButton?.closest("[data-sidebar-pane]"))
.toHaveAttribute("data-sidebar-pane", "websocket:chat-pane");
fireEvent.click(within(sidebar).getByRole("button", { name: "Beta" }));
await waitFor(() => {
const nextGrid = screen.getByTestId("pane-grid");
expect(Array.from(nextGrid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Beta"]);
expect(nextGrid).toHaveAttribute("data-layout", "columns");
});
fireEvent.click(within(sidebar).getByRole("button", { name: "Alpha" }));
await waitFor(() => {
const restoredGrid = screen.getByTestId("pane-grid");
expect(Array.from(restoredGrid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha", "New topic"]);
expect(restoredGrid).toHaveAttribute("data-layout", "rows");
});
fireEvent.pointerDown(within(sidebar).getByRole("button", {
name: "New topic pane actions",
}), { button: 0, ctrlKey: false });
fireEvent.click(screen.getByRole("menuitem", {
name: "Move New topic to its own topic",
}));
await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1));
expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2);
});
it("opens search from the keyboard shortcut", async () => {
mockSessions = [
{
+259 -7
View File
@@ -1,8 +1,8 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { createEvent, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatList } from "@/components/ChatList";
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
import { PANE_DRAG_TYPE, SESSION_DRAG_TYPE } from "@/lib/session-drag";
import type { ChatSummary } from "@/lib/types";
function session(overrides: Partial<ChatSummary>): ChatSummary {
@@ -42,6 +42,26 @@ function rect({
} as DOMRect;
}
function dragOverAt(
element: Element,
clientY: number,
dataTransfer: Record<string, unknown>,
): void {
const event = createEvent.dragOver(element, { dataTransfer });
Object.defineProperty(event, "clientY", { value: clientY });
fireEvent(element, event);
}
function dropAt(
element: Element,
clientY: number,
dataTransfer: Record<string, unknown>,
): void {
const event = createEvent.drop(element, { dataTransfer });
Object.defineProperty(event, "clientY", { value: clientY });
fireEvent(element, event);
}
describe("ChatList", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -82,7 +102,13 @@ describe("ChatList", () => {
fireEvent.dragEnd(reference, { dataTransfer });
});
it("reorders chats around a Codex-style insertion line", () => {
it("reorders topics with a displaced-neighbor preview instead of an insertion line", () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
left: 0,
top: 0,
width: 284,
height: 32,
}));
const onReorderSessions = vi.fn();
const sessions = [
session({ chatId: "alpha", title: "Alpha" }),
@@ -112,10 +138,14 @@ describe("ChatList", () => {
};
fireEvent.dragStart(screen.getByRole("button", { name: "Alpha" }), { dataTransfer });
const charlieRow = screen.getByRole("button", { name: "Charlie" }).closest("li")!;
fireEvent.dragOver(charlieRow, { clientY: 1, dataTransfer });
expect(charlieRow.querySelector("[data-session-drop-edge='after']"))
.toBeInTheDocument();
fireEvent.drop(charlieRow, { clientY: 1, dataTransfer });
dragOverAt(charlieRow, 24, dataTransfer);
expect(document.querySelector("[data-session-drop-edge]")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Bravo" }).closest("li"))
.toHaveAttribute("data-session-displaced", "true");
expect(charlieRow).toHaveStyle({ transform: "translateY(-32px)" });
expect(screen.getByRole("button", { name: "Alpha" }).closest("li"))
.toHaveAttribute("data-session-dragging", "true");
dropAt(charlieRow, 24, dataTransfer);
expect(onReorderSessions).toHaveBeenCalledWith([
"websocket:bravo",
@@ -152,6 +182,228 @@ describe("ChatList", () => {
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
});
it("shows every tab's pane membership in the sidebar tree", async () => {
const onSelect = vi.fn();
const onSelectPane = vi.fn();
const onDetachPane = vi.fn();
const onPromotePane = vi.fn();
const onRequestRename = vi.fn();
const onAttachPane = vi.fn();
render(
<ChatList
sessions={[
session({ chatId: "root", title: "Root topic" }),
session({ chatId: "target", title: "Target tab" }),
]}
activeKey="websocket:root"
paneGroups={{
"websocket:root": {
topicKey: "websocket:root",
activePaneKey: "websocket:child",
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:child", chatId: "child", title: "Research pane" },
],
},
"websocket:target": {
topicKey: "websocket:target",
activePaneKey: "websocket:target-child",
panes: [
{ key: "websocket:target", chatId: "target", title: "Target tab" },
{
key: "websocket:target-child",
chatId: "target-child",
title: "Target research",
},
],
},
}}
onSelect={onSelect}
onSelectPane={onSelectPane}
onDetachPane={onDetachPane}
onPromotePane={onPromotePane}
paneAcceptingTabKeys={["websocket:target"]}
onAttachPane={onAttachPane}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={onRequestRename}
onToggleArchive={vi.fn()}
/>,
);
const child = screen.getByRole("button", { name: "Research pane" });
expect(child.closest("[data-sidebar-pane]"))
.toHaveAttribute("data-sidebar-pane", "websocket:child");
expect(child).toHaveAttribute("aria-current", "true");
const targetTabRow = screen.getByRole("button", { name: "Target tab" }).closest("li")!;
const targetChild = within(targetTabRow).getByRole("button", {
name: "Target research",
});
expect(targetChild.closest("[data-sidebar-pane]"))
.toHaveAttribute("data-sidebar-pane", "websocket:target-child");
expect(targetChild).not.toHaveAttribute("aria-current");
fireEvent.click(targetChild);
expect(onSelectPane).toHaveBeenCalledWith(
"websocket:target",
"websocket:target-child",
);
fireEvent.click(child);
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
fireEvent.click(screen.getByRole("button", { name: "Root topic" }));
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:root");
expect(onSelect).not.toHaveBeenCalled();
fireEvent.pointerDown(screen.getByRole("button", {
name: "Research pane pane actions",
}), { button: 0, ctrlKey: false });
const moveToTab = await screen.findByRole("menuitem", { name: "Move to tab" });
fireEvent.pointerMove(moveToTab, { pointerType: "mouse" });
fireEvent.click(await screen.findByRole("menuitem", { name: "Target tab" }));
expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target");
fireEvent.pointerDown(screen.getByRole("button", {
name: "Research pane pane actions",
}), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", {
name: "Move Research pane to its own topic",
}));
expect(onDetachPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
};
fireEvent.dragStart(child, { dataTransfer });
expect(child.closest("li")).toHaveAttribute("data-pane-dragging", "true");
expect(child.closest("li")).not.toHaveClass("opacity-0");
const targetTab = screen.getByRole("button", { name: "Target tab" });
dragOverAt(targetTab.closest("li")!, 0, dataTransfer);
expect(targetTab.closest("li"))
.toHaveAttribute("data-tab-attach-target", "true");
expect(within(targetTab.closest("li")!).getByRole("status", {
name: "Move Research pane into Target tab",
})).toHaveTextContent("Research pane");
dropAt(targetTab.closest("li")!, 0, dataTransfer);
expect(dataTransfer.setData).toHaveBeenCalledWith(
PANE_DRAG_TYPE,
JSON.stringify({
paneKey: "websocket:child",
sourceTabKey: "websocket:root",
}),
);
expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target");
});
it("selects a whole tab or individual panes for one bulk delete", async () => {
const onRequestDeleteMany = vi.fn();
render(
<ChatList
sessions={[
session({ chatId: "root", title: "Root topic" }),
session({ chatId: "target", title: "Target tab" }),
]}
activeKey="websocket:root"
paneGroups={{
"websocket:root": {
topicKey: "websocket:root",
activePaneKey: "websocket:root",
panes: [
{ key: "websocket:root", chatId: "root", title: "Root topic" },
{ key: "websocket:child", chatId: "child", title: "Research pane" },
],
},
}}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onRequestDeleteMany={onRequestDeleteMany}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
fireEvent.pointerDown(screen.getByRole("button", {
name: "Topic actions for Root topic",
}), { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
expect(screen.getByRole("button", { name: "Root topic" }))
.toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Research pane" }))
.toHaveAttribute("aria-pressed", "true");
expect(screen.getByText("2 selected")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Target tab" }));
expect(screen.getByText("3 selected")).toBeInTheDocument();
fireEvent.click(within(screen.getByTestId("delete-selection-bar")).getByRole("button", {
name: "Delete",
}));
expect(onRequestDeleteMany).toHaveBeenCalledWith([
{ key: "websocket:root", label: "Root topic" },
{ key: "websocket:child", label: "Research pane" },
{ key: "websocket:target", label: "Target tab" },
]);
expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument();
});
it("reattaches a one-pane tab through the center of another tab", () => {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
left: 0,
top: 0,
width: 284,
height: 32,
}));
const onAttachPane = vi.fn();
const onReorderSessions = vi.fn();
const dataTransfer = {
effectAllowed: "",
dropEffect: "",
setData: vi.fn(),
};
render(
<ChatList
sessions={[
session({ chatId: "detached", title: "Detached pane" }),
session({ chatId: "target", title: "Target tab" }),
]}
activeKey={null}
attachableTabKeys={["websocket:detached", "websocket:target"]}
paneAcceptingTabKeys={["websocket:detached", "websocket:target"]}
onAttachPane={onAttachPane}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
onReorderSessions={onReorderSessions}
/>,
);
const detached = screen.getByRole("button", { name: "Detached pane" });
fireEvent.dragStart(detached, {
dataTransfer,
});
expect(detached.closest("li"))
.toHaveAttribute("data-session-dragging", "true");
const target = screen.getByRole("button", { name: "Target tab" }).closest("li")!;
dragOverAt(target, 16, dataTransfer);
expect(target).toHaveAttribute("data-tab-attach-target", "true");
expect(document.querySelector("[data-session-displaced='true']"))
.not.toBeInTheDocument();
dropAt(target, 16, dataTransfer);
expect(onAttachPane).toHaveBeenCalledWith(
"websocket:detached",
"websocket:target",
);
expect(onReorderSessions).not.toHaveBeenCalled();
});
it("shows temporary chats separately and lets the user reopen or close them", async () => {
const temporarySession = session({
key: "temporary:temporary-one",
+139
View File
@@ -0,0 +1,139 @@
import { createPortal } from "react-dom";
import { useState } from "react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
import {
EMPTY_WORKBENCH_STATE,
addWorkbenchPane,
focusWorkbenchPane,
setWorkbenchLayout,
workbenchTab,
} from "@/components/workbench/workbench-model";
function rect(left: number, top: number, width: number, height: number): DOMRect {
return {
x: left,
y: top,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
};
}
function WorkbenchHarness() {
const [state, setState] = useState(() => (
addWorkbenchPane(EMPTY_WORKBENCH_STATE, "alpha", "beta")
));
const tab = workbenchTab(state, "alpha");
const titles: Record<string, string> = { alpha: "Alpha", beta: "Beta" };
return (
<PaneWorkbench
panes={tab.paneKeys.map((key) => ({ key, title: titles[key] }))}
activePaneKey={tab.activePaneKey}
layout={tab.layout}
onActivatePane={(key) => setState((current) => (
focusWorkbenchPane(current, "alpha", key)
))}
onAddPane={vi.fn()}
onLayoutChange={(layout) => setState((current) => (
setWorkbenchLayout(current, "alpha", layout)
))}
renderPane={(pane, context) => (
<>
<button type="button">Focus {pane.title}</button>
{context.headerPortalTarget && context.active ? createPortal(
context.headerActions,
context.headerPortalTarget,
) : null}
{context.composerPortalTarget ? createPortal(
<div hidden={!context.active}>
<textarea aria-label={`Composer ${pane.title}`} />
</div>,
context.composerPortalTarget,
) : null}
</>
)}
/>
);
}
describe("PaneWorkbench", () => {
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
const originalAnimate = HTMLElement.prototype.animate;
const animate = vi.fn(() => ({
addEventListener: vi.fn(),
cancel: vi.fn(),
}) as unknown as Animation);
beforeEach(() => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})));
HTMLElement.prototype.animate = animate;
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
if (!this.classList.contains("workbench-pane")) {
return originalGetBoundingClientRect.call(this);
}
const layout = this.parentElement?.dataset.layout;
const index = Array.from(this.parentElement?.children ?? []).indexOf(this);
return layout === "rows"
? rect(0, index * 500, 1000, 500)
: rect(index * 500, 0, 500, 1000);
};
});
afterEach(() => {
HTMLElement.prototype.animate = originalAnimate;
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("focuses without reordering and keeps only the focused composer visible", () => {
render(<WorkbenchHarness />);
const grid = screen.getByTestId("pane-grid");
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha", "Beta"]);
expect(screen.getByLabelText("Composer Beta")).toBeVisible();
expect(screen.getByLabelText("Composer Alpha")).not.toBeVisible();
fireEvent.pointerDown(
within(screen.getByRole("region", { name: "Alpha" }))
.getByRole("button", { name: "Focus Alpha" }),
);
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
.toEqual(["Alpha", "Beta"]);
expect(screen.getByLabelText("Composer Alpha")).toBeVisible();
expect(screen.getByLabelText("Composer Beta")).not.toBeVisible();
});
it("keeps one shared layout control and animates geometry changes", async () => {
render(<WorkbenchHarness />);
const header = screen.getByTestId("workbench-header-host");
expect(within(header).getAllByRole("button", { name: "Pane layout" })).toHaveLength(1);
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
button: 0,
ctrlKey: false,
});
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "rows");
await waitFor(() => expect(animate).toHaveBeenCalledTimes(2));
});
});
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import {
EMPTY_WORKBENCH_STATE,
MAX_WORKBENCH_PANES,
addWorkbenchPane,
attachWorkbenchPane,
detachWorkbenchPane,
ensureWorkbenchTab,
focusWorkbenchPane,
parseWorkbenchState,
promoteWorkbenchPane,
reconcileWorkbench,
setWorkbenchLayout,
workbenchChildPaneKeys,
workbenchTab,
} from "@/components/workbench/workbench-model";
describe("workbench model", () => {
it("gives every topic its own one-pane tab by default", () => {
const state = ensureWorkbenchTab(EMPTY_WORKBENCH_STATE, "topic-a");
expect(workbenchTab(state, "topic-a")).toEqual({
paneKeys: ["topic-a"],
activePaneKey: "topic-a",
layout: "columns",
});
expect(state.tabs["topic-b"]).toBeUndefined();
});
it("keeps pane membership, focus, and layout scoped to a tab", () => {
let state = ensureWorkbenchTab(EMPTY_WORKBENCH_STATE, "topic-a");
state = ensureWorkbenchTab(state, "topic-b");
state = addWorkbenchPane(state, "topic-a", "topic-c");
state = setWorkbenchLayout(state, "topic-a", "main-stack");
expect(workbenchTab(state, "topic-a")).toEqual({
paneKeys: ["topic-a", "topic-c"],
activePaneKey: "topic-c",
layout: "main-stack",
});
expect(workbenchTab(state, "topic-b")).toEqual({
paneKeys: ["topic-b"],
activePaneKey: "topic-b",
layout: "columns",
});
});
it("focuses without reordering and promotes only when asked", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "topic-b");
state = addWorkbenchPane(state, "topic-a", "topic-c");
state = focusWorkbenchPane(state, "topic-a", "topic-b");
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([
"topic-a",
"topic-b",
"topic-c",
]);
state = promoteWorkbenchPane(state, "topic-a", "topic-b");
expect(workbenchTab(state, "topic-a")).toMatchObject({
paneKeys: ["topic-b", "topic-a", "topic-c"],
activePaneKey: "topic-b",
});
});
it("detaches child panes, keeps the root, and chooses the adjacent focus", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "topic-b");
state = addWorkbenchPane(state, "topic-a", "topic-c");
state = focusWorkbenchPane(state, "topic-a", "topic-b");
state = detachWorkbenchPane(state, "topic-a", "topic-b");
expect(workbenchTab(state, "topic-a")).toMatchObject({
paneKeys: ["topic-a", "topic-c"],
activePaneKey: "topic-c",
});
state = detachWorkbenchPane(state, "topic-a", "topic-c");
state = detachWorkbenchPane(state, "topic-a", "topic-a");
expect(workbenchTab(state, "topic-a").paneKeys).toEqual(["topic-a"]);
});
it("moves a pane between tabs and can reattach a one-pane tab", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
state = ensureWorkbenchTab(state, "topic-b");
state = attachWorkbenchPane(state, "topic-b", "pane-a");
expect(workbenchTab(state, "topic-a")).toMatchObject({
paneKeys: ["topic-a"],
activePaneKey: "topic-a",
});
expect(workbenchTab(state, "topic-b")).toMatchObject({
paneKeys: ["topic-b", "pane-a"],
activePaneKey: "pane-a",
});
state = ensureWorkbenchTab(state, "topic-c");
state = attachWorkbenchPane(state, "topic-b", "topic-c");
expect(state.tabs["topic-c"]).toBeUndefined();
expect(workbenchTab(state, "topic-b").paneKeys).toEqual([
"topic-b",
"pane-a",
"topic-c",
]);
});
it("does not collapse a multi-pane tab into another tab", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
state = ensureWorkbenchTab(state, "topic-b");
expect(attachWorkbenchPane(state, "topic-b", "topic-a")).toBe(state);
});
it("caps every tab at four panes", () => {
let state = EMPTY_WORKBENCH_STATE;
for (let index = 1; index <= MAX_WORKBENCH_PANES; index += 1) {
state = addWorkbenchPane(state, "topic-a", `pane-${index}`);
}
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([
"topic-a",
"pane-1",
"pane-2",
"pane-3",
]);
const beforeAttach = state;
state = attachWorkbenchPane(state, "topic-a", "standalone");
expect(state).toBe(beforeAttach);
});
it("identifies only sessions attached beneath another topic", () => {
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
state = addWorkbenchPane(state, "topic-b", "pane-b");
expect(workbenchChildPaneKeys(state)).toEqual(new Set(["pane-a", "pane-b"]));
state = detachWorkbenchPane(state, "topic-a", "pane-a");
expect(workbenchChildPaneKeys(state)).toEqual(new Set(["pane-b"]));
});
it("repairs persisted state and removes deleted sessions", () => {
const parsed = parseWorkbenchState(JSON.stringify({
version: 2,
tabs: {
"topic-a": {
paneKeys: ["topic-a", "topic-b", "topic-b", 9],
activePaneKey: "missing",
layout: "unknown",
},
deleted: {
paneKeys: ["deleted"],
activePaneKey: "deleted",
layout: "grid",
},
},
}));
const reconciled = reconcileWorkbench(parsed, new Set(["topic-a"]));
expect(reconciled).toEqual({
version: 2,
tabs: {
"topic-a": {
paneKeys: ["topic-a"],
activePaneKey: "topic-a",
layout: "columns",
},
},
});
expect(parseWorkbenchState(JSON.stringify({ version: 1, tabs: {} })))
.toEqual(EMPTY_WORKBENCH_STATE);
expect(parseWorkbenchState("not-json")).toEqual(EMPTY_WORKBENCH_STATE);
});
});