diff --git a/webui/src/App.tsx b/webui/src/App.tsx index d655d4f05..46311b496 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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(() => { + 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({ - { + if (!activeKey) return; + setWorkbenchState((current) => ( + setWorkbenchLayout(current, activeKey, layout) + )); + }} + renderPane={(pane, context) => { + if (!paneChromeEnabled) { + return ( + + ); + } + + 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 ( + 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} + /> + ); + }} /> {view !== "chat" && ( @@ -2398,7 +2718,8 @@ function Shell({ setPendingDelete(null)} onConfirm={onConfirmDelete} diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index e4abc146f..c79b4a8fb 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -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; + 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(null); + const [tabAttachTargetKey, setTabAttachTargetKey] = useState(null); + const tabAttachTargetRef = useRef(null); + const tabRowRefs = useRef(new Map()); + const pendingTabRectsRef = useRef | null>(null); + const tabLayoutAnimationsRef = useRef(new Map()); + const [deleteSelectionMode, setDeleteSelectionMode] = useState(false); + const [selectedDeleteKeys, setSelectedDeleteKeys] = useState>( + () => new Set(), + ); const activeRowRef = useRef(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(); + 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(() => ({ 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(); + 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 (
@@ -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({
COLLAPSED_CHATS_VISIBLE_COUNT; + const reorderOffsets = sessionReorderOffsets( + visibleSessions.map((session) => session.key), + draggedSessionKey, + sessionDropTarget, + draggedSessionHeight, + ); return (
@@ -298,12 +509,28 @@ export const ChatList = memo(function ChatList({ {group.kind === "project" && collapsedGroups[group.id] ? null : (
    {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 (
  • { + 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 ? ( - - ) : null}
    - + {!deleteSelectionMode ? @@ -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 ? ( + onPromotePane(s.key, s.key)}> + + {t("workbench.promotePane", { + defaultValue: "Make {{title}} the primary pane", + title, + })} + + ) : null} onTogglePin(s.key)} > @@ -468,18 +803,71 @@ export const ChatList = memo(function ChatList({ )} {isArchived ? t("chat.unarchive") : t("chat.archive")} + {attachableTabs.has(s.key) && onAttachPane ? ( + target.key !== s.key)} + onMove={(targetKey) => onAttachPane(s.key, targetKey)} + /> + ) : null} + beginDeleteSelection(tabDeleteKeys)} + > + + {t("chat.select", { defaultValue: "Select" })} + { - window.setTimeout(() => onRequestDelete(s.key, title), 0); + window.setTimeout(() => requestDeleteKeys(tabDeleteKeys), 0); }} > {t("chat.delete")} - + : null}
    + {paneCount > 1 || isAttachTarget ? ( + ( + 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}
  • ); })} @@ -510,11 +898,329 @@ export const ChatList = memo(function ChatList({
) : null} + {deleteSelectionMode ? ( +
+ + + {t("chat.selectedCount", { + defaultValue: "{{count}} selected", + count: selectedDeleteKeys.size, + })} + + +
+ ) : null}
); }); +function sessionReorderOffsets( + keys: string[], + draggedKey: string | null, + target: { edge: "before" | "after"; key: string } | null, + draggedHeight: number, +): Map { + const offsets = new Map(); + 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; + running: ReadonlySet; + updated: ReadonlySet; + 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; + onToggleDeleteSelection: (keys: string[]) => void; + onBeginDeleteSelection: (keys: string[]) => void; + dropPreview: { paneTitle: string; targetTitle: string } | null; + draggedPaneKey: string | null; + onPaneDragStart: (event: DragEvent, pane: DraggedPane) => void; + onPaneDragEnd: () => void; + actionMenuPortalContainer?: HTMLElement | null; +}) { + const { t } = useTranslation(); + const childPanes = group.panes.filter((pane) => pane.key !== group.topicKey); + + return ( +
    + {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 ( +
  • +
    + + + {!deleteSelectionMode ? + + + + event.preventDefault()} + > + {index > 0 && onPromotePane ? ( + onPromotePane(group.topicKey, pane.key)}> + + {t("workbench.promotePane", { + defaultValue: "Make {{title}} the primary pane", + title: pane.title, + })} + + ) : null} + onRequestRename(pane.key, pane.title)} + > + + {t("chat.rename")} + + {onDetachPane ? ( + onDetachPane(group.topicKey, pane.key)}> + + {t("workbench.detachPane", { + defaultValue: "Move {{title}} to its own topic", + title: pane.title, + })} + + ) : null} + {onAttachPane ? ( + onAttachPane(pane.key, targetKey)} + /> + ) : null} + onBeginDeleteSelection([pane.key])} + > + + {t("chat.select", { defaultValue: "Select" })} + + { + window.setTimeout(() => onRequestDelete(pane.key, pane.title), 0); + }} + > + + {t("chat.delete")} + + + : null} +
    +
  • + ); + })} + {dropPreview ? ( +
  • +
    + + {dropPreview.paneTitle} +
    +
  • + ) : null} +
+ ); +} + +function SelectionIndicator({ + checked, + partial, +}: { + checked: boolean; + partial: boolean; +}) { + const Icon = partial ? SquareMinus : checked ? SquareCheckBig : Square; + return ( + + ); +} + +function MoveToTabSubmenu({ + targets, + onMove, +}: { + targets: Array<{ key: string; title: string }>; + onMove: (targetKey: string) => void; +}) { + const { t } = useTranslation(); + if (targets.length === 0) return null; + return ( + + + + {t("workbench.moveToTab", { defaultValue: "Move to tab" })} + + + {targets.map((target) => ( + onMove(target.key)}> + {target.title} + + ))} + + + ); +} + function TemporaryChatSection({ sessions, activeKey, diff --git a/webui/src/components/DeleteConfirm.tsx b/webui/src/components/DeleteConfirm.tsx index f7ccb5c2a..d1717de4e 100644 --- a/webui/src/components/DeleteConfirm.tsx +++ b/webui/src/components/DeleteConfirm.tsx @@ -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({ - {t("deleteConfirm.title", { title })} + {multiple + ? t("deleteConfirm.titleMany", { + defaultValue: "Delete {{count}} topics and panes?", + count, + }) + : t("deleteConfirm.title", { title })} {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")} {hasAutomations ? (
diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index f41ebcdf6..02fd3817e 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -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; + 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} diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 50861de6d..439e9c385 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -186,6 +186,7 @@ interface ThreadComposerProps { ) => boolean | void | Promise; 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", diff --git a/webui/src/components/thread/ThreadHeader.tsx b/webui/src/components/thread/ThreadHeader.tsx index ef43e96bb..f669f0f33 100644 --- a/webui/src/components/thread/ThreadHeader.tsx +++ b/webui/src/components/thread/ThreadHeader.tsx @@ -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({ )} >
- - {!minimal ? ( + {!hideSidebarToggle ? ( + + ) : null} + {!minimal && !hideTitle ? (
{title}
@@ -76,6 +84,7 @@ export function ThreadHeader({
{sessionInfoAction} {promptNavigatorAction} + {actions} {onTemporaryChatEnabledChange ? ( diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index f53463d1f..164e73387 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -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({ ) : undefined; + const threadHeader = !hideHeader ? ( + + ) : null; + return (
- {!hideHeader ? ( - - ) : null} + {headerPortalTarget === undefined ? threadHeader : null} @@ -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({ />
+ {headerPortalTarget && headerActive + ? createPortal(threadHeader, headerPortalTarget) + : null} + {composerPortalTarget ? createPortal( + , + composerPortalTarget, + ) : null} {filePreviewPath && historyKey ? ( 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
) : ( -
+
{emptyState}
)} -
{ - 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 ? (
{ + 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", )} >
- {composer} +
+ {composer} +
-
+ ) : null} -
+ {hasComposer ? ( +
+ ) : null}
{!hasMessages ?
: null}
diff --git a/webui/src/components/ui/dropdown-menu.tsx b/webui/src/components/ui/dropdown-menu.tsx index f52b879d2..c6cbec579 100644 --- a/webui/src/components/ui/dropdown-menu.tsx +++ b/webui/src/components/ui/dropdown-menu.tsx @@ -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, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 6, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + export { DropdownMenu, DropdownMenuContent, @@ -123,5 +159,8 @@ export { DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, }; diff --git a/webui/src/components/workbench/PaneWorkbench.tsx b/webui/src/components/workbench/PaneWorkbench.tsx new file mode 100644 index 000000000..d47734c44 --- /dev/null +++ b/webui/src/components/workbench/PaneWorkbench.tsx @@ -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 ( + + + + + {label} + + ); +} + +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(null); + const [composerPortalTarget, setComposerPortalTarget] = useState(null); + const paneRefs = useRef(new Map()); + const lastRectsRef = useRef(new Map()); + const pendingRectsRef = useRef | null>(null); + const animationsRef = useRef(new Map()); + const paneOrder = useMemo(() => panes.map((pane) => pane.key).join("\u0000"), [panes]); + + const measurePanes = useCallback(() => { + const rects = new Map(); + 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, + ) => { + activatePane(key, event.target); + }, [activatePane]); + + const handlePaneFocus = useCallback((key: string, event: FocusEvent) => { + 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 ? ( +
+ + + + + event.preventDefault()} + > + + {t("workbench.layout", { defaultValue: "Pane layout" })} + + + { + const next = value as WorkbenchLayout; + if (next === layout) return; + captureLayout(); + onLayoutChange(next); + }} + > + {LAYOUT_CONTROLS.map((control) => ( + + + {t(`workbench.layouts.${control.layout}`, { + defaultValue: control.label, + })} + + ))} + + + + { + captureLayout(); + onAddPane(); + }} + /> +
+ ) : null; + + return ( +
+ + {chrome ? ( +
+
+
+ ) : null} +
+
1 && "gap-px bg-border/55", + )} + style={gridStyle} + > + {panes.map((pane, index) => { + const active = pane.key === activePaneKey; + const hidden = effectiveLayout === "monocle" && !active; + + return ( +
{ + 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, + })} +
+ ); + })} +
+
+ + {chrome ? ( +
+
+
+ ) : null} +
+
+ ); +} diff --git a/webui/src/components/workbench/workbench-model.ts b/webui/src/components/workbench/workbench-model.ts new file mode 100644 index 000000000..fa486c43c --- /dev/null +++ b/webui/src/components/workbench/workbench-model.ts @@ -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; +} + +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 + : {}; + 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, +): WorkbenchState { + const tabs: Record = {}; + 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 { + const childKeys = new Set(); + for (const [tabKey, tab] of Object.entries(state.tabs)) { + for (const paneKey of tab.paneKeys) { + if (paneKey !== tabKey) childKeys.add(paneKey); + } + } + return childKeys; +} diff --git a/webui/src/globals.css b/webui/src/globals.css index 215ed1d51..b0cf216bd 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -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%, diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index b0b5637a0..c77b2ca1f 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -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", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index ae2e9fb3e..21cb0fcb7 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -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", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index aa32089c6..18f8cec7f 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -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", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 81326edf9..8e6681d08 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -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", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 08fe99bd7..137857c24 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -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": "閉じる", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index dd3bfdf60..94b1c46e4 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -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": "닫기", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index eef0bea72..b9b8f3bee 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -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", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 98ca6c1b3..15843fd6d 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -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", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 265f81797..d59e341d4 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -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": "关闭", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index a2eef59f5..882b08ec7 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -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": "關閉", diff --git a/webui/src/lib/session-drag.ts b/webui/src/lib/session-drag.ts index 0a4339cc9..c089beaea 100644 --- a/webui/src/lib/session-drag.ts +++ b/webui/src/lib/session-drag.ts @@ -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; + 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)); +} diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 32abc7017..f6b25f568 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -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(); 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(); + + 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(); + + 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 = [ { diff --git a/webui/src/tests/chat-list.test.tsx b/webui/src/tests/chat-list.test.tsx index fa2e3cb79..cef3424b6 100644 --- a/webui/src/tests/chat-list.test.tsx +++ b/webui/src/tests/chat-list.test.tsx @@ -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 { @@ -42,6 +42,26 @@ function rect({ } as DOMRect; } +function dragOverAt( + element: Element, + clientY: number, + dataTransfer: Record, +): void { + const event = createEvent.dragOver(element, { dataTransfer }); + Object.defineProperty(event, "clientY", { value: clientY }); + fireEvent(element, event); +} + +function dropAt( + element: Element, + clientY: number, + dataTransfer: Record, +): 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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", diff --git a/webui/src/tests/pane-workbench.test.tsx b/webui/src/tests/pane-workbench.test.tsx new file mode 100644 index 000000000..1f4988b67 --- /dev/null +++ b/webui/src/tests/pane-workbench.test.tsx @@ -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 = { alpha: "Alpha", beta: "Beta" }; + + return ( + ({ 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) => ( + <> + + {context.headerPortalTarget && context.active ? createPortal( + context.headerActions, + context.headerPortalTarget, + ) : null} + {context.composerPortalTarget ? createPortal( +