From 89289bb31d0774f636b022d3cb5ad8c4795ec320 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Sun, 15 Mar 2026 00:31:55 -0700 Subject: [PATCH] feat(chat): multi-session chat tabs with stop controls UI Tab-based multi-chat system supporting concurrent parent and subagent sessions, per-session stop controls, layout/scroll fixes, and attachment display improvements. --- apps/web/app/components/chat-message.tsx | 58 +- apps/web/app/components/chat-panel.tsx | 102 ++- .../workspace/chat-sessions-sidebar.tsx | 120 ++- apps/web/app/components/workspace/tab-bar.tsx | 21 +- apps/web/app/globals.css | 5 + apps/web/app/workspace/workspace-content.tsx | 818 +++++++++++------- apps/web/lib/chat-session-registry.ts | 61 ++ apps/web/lib/chat-tabs.test.ts | 124 +++ apps/web/lib/chat-tabs.ts | 178 ++++ apps/web/lib/tab-state.ts | 18 +- 10 files changed, 1096 insertions(+), 409 deletions(-) create mode 100644 apps/web/lib/chat-session-registry.ts create mode 100644 apps/web/lib/chat-tabs.test.ts create mode 100644 apps/web/lib/chat-tabs.ts diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx index d02123c6be2..7967acb995b 100644 --- a/apps/web/app/components/chat-message.tsx +++ b/apps/web/app/components/chat-message.tsx @@ -10,6 +10,7 @@ import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { ChainOfThought, type ChainPart } from "./chain-of-thought"; +import { isStatusReasoningText } from "./chat-stream-status"; import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks"; import { splitDiffBlocks, hasDiffBlocks } from "@/lib/diff-blocks"; import type { ReportConfig } from "./charts/types"; @@ -55,13 +56,16 @@ type MessageSegment = | { type: "subagent-card"; task: string; label?: string; sessionKey?: string; status: "running" | "done" | "error" }; /** Map AI SDK tool state string to a simplified status */ -function toolStatus(state: string): "running" | "done" | "error" { - if (state === "output-available") { - return "done"; - } - if (state === "error") { +function toolStatus( + state: string, + preliminary = false, +): "running" | "done" | "error" { + if (state === "output-error" || state === "error") { return "error"; } + if (state === "output-available" && !preliminary) { + return "done"; + } return "running"; } @@ -115,18 +119,10 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] { text: string; state?: string; }; - // Skip lifecycle/compaction status labels — they add noise - // (e.g. "Preparing response...", "Optimizing session context...") - const statusLabels = [ - "Preparing response...", - "Optimizing session context...", - "Waiting for subagent results...", - "Waiting for subagents...", - ]; - const isStatus = statusLabels.some((l) => - rp.text.startsWith(l), - ); - if (!isStatus) { + // Skip lifecycle/compaction status labels in the thought body. + // The active stream row renders them separately so they stay visible + // without cluttering the permanent transcript. + if (!isStatusReasoningText(rp.text)) { chain.push({ kind: "reasoning", text: rp.text, @@ -141,6 +137,7 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] { state: string; input?: unknown; output?: unknown; + preliminary?: boolean; }; if (tp.toolName === "sessions_spawn") { flush(true); @@ -149,13 +146,19 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] { const task = typeof args?.task === "string" ? args.task : "Subagent task"; const label = typeof args?.label === "string" ? args.label : undefined; const sessionKey = typeof out?.sessionKey === "string" ? out.sessionKey : undefined; - segments.push({ type: "subagent-card", task, label, sessionKey, status: toolStatus(tp.state) }); + segments.push({ + type: "subagent-card", + task, + label, + sessionKey, + status: toolStatus(tp.state, tp.preliminary === true), + }); } else { chain.push({ kind: "tool", toolName: tp.toolName, toolCallId: tp.toolCallId, - status: toolStatus(tp.state), + status: toolStatus(tp.state, tp.preliminary === true), args: asRecord(tp.input), output: asRecord(tp.output), }); @@ -175,6 +178,7 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] { args?: unknown; result?: unknown; errorText?: string; + preliminary?: boolean; }; const resolvedToolName = tp.title ?? tp.toolName ?? part.type.replace("tool-", ""); if (resolvedToolName === "sessions_spawn") { @@ -186,19 +190,25 @@ function groupParts(parts: UIMessage["parts"]): MessageSegment[] { const sessionKey = typeof out?.sessionKey === "string" ? out.sessionKey : undefined; const resolvedState = tp.state ?? - (tp.errorText ? "error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available"); - segments.push({ type: "subagent-card", task, label, sessionKey, status: toolStatus(resolvedState) }); + (tp.errorText ? "output-error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available"); + segments.push({ + type: "subagent-card", + task, + label, + sessionKey, + status: toolStatus(resolvedState, tp.preliminary === true), + }); } else { // Persisted tool-invocation parts have no state field but // include result/output/errorText to indicate completion. const resolvedState = tp.state ?? - (tp.errorText ? "error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available"); + (tp.errorText ? "output-error" : ("result" in tp || "output" in tp) ? "output-available" : "input-available"); chain.push({ kind: "tool", toolName: resolvedToolName, toolCallId: tp.toolCallId, - status: toolStatus(resolvedState), + status: toolStatus(resolvedState, tp.preliminary === true), args: asRecord(tp.input) ?? asRecord(tp.args), output: asRecord(tp.output) ?? asRecord(tp.result), }); @@ -784,7 +794,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS if (attachmentInfo) { return (
- {!richHtml && } + {(attachmentInfo.message || richHtml) && (
; output?: Record; + preliminary?: boolean; }; export function createStreamParser() { @@ -685,6 +691,7 @@ export function createStreamParser() { p.type === "dynamic-tool" && p.toolCallId === event.toolCallId ) { + p.preliminary = true; p.output = (event.output as Record< string, @@ -701,7 +708,13 @@ export function createStreamParser() { p.type === "dynamic-tool" && p.toolCallId === event.toolCallId ) { - p.state = "output-available"; + if (event.preliminary === true) { + p.preliminary = true; + p.state = "input-available"; + } else { + delete p.preliminary; + p.state = "output-available"; + } p.output = (event.output as Record< string, @@ -814,6 +827,8 @@ type ChatPanelProps = { onBack?: () => void; /** Hide the header action buttons (when they're rendered elsewhere, e.g. tab bar). */ hideHeaderActions?: boolean; + /** Called whenever the panel's runtime state changes. */ + onRuntimeStateChange?: (state: ChatPanelRuntimeState) => void; }; export const ChatPanel = forwardRef( @@ -836,6 +851,7 @@ export const ChatPanel = forwardRef( subagentLabel, onBack, hideHeaderActions, + onRuntimeStateChange, }, ref, ) { @@ -962,6 +978,25 @@ export const ChatPanel = forwardRef( status === "submitted" || isReconnecting; + useEffect(() => { + onRuntimeStateChange?.({ + sessionId: currentSessionId, + sessionKey: subagentSessionKey ?? null, + isStreaming, + status, + isReconnecting, + loadingSession, + }); + }, [ + currentSessionId, + subagentSessionKey, + isStreaming, + status, + isReconnecting, + loadingSession, + onRuntimeStateChange, + ]); + // Stream stall detection: if we stay in "submitted" (no first // token received) for too long, surface an error and reset. const stallTimerRef = useRef | null>(null); @@ -1955,29 +1990,18 @@ export const ChatPanel = forwardRef( [], ); - // ── Status label ── + // ── Active stream status row ── - const _statusLabel = loadingSession - ? "Loading session..." - : isReconnecting - ? "Resuming stream..." - : status === "ready" - ? "Ready" - : status === "submitted" - ? "Thinking..." - : status === "streaming" - ? (hasRunningSubagents ? "Waiting for subagents..." : "Streaming...") - : status === "error" - ? "Error" - : status; - - // Show an inline Unicode spinner in the message flow when the AI - // is thinking/streaming but hasn't produced visible text yet. const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null; - const lastAssistantHasText = - lastMsg?.role === "assistant" && - lastMsg.parts.some((p) => p.type === "text" && (p as { text: string }).text.length > 0); - const showInlineSpinner = isStreaming && !lastAssistantHasText; + const lastAssistantHasText = hasAssistantText(lastMsg); + const streamActivityLabel = getStreamActivityLabel({ + loadingSession, + isReconnecting, + status, + hasRunningSubagents, + lastMessage: lastMsg, + }); + const showStreamActivity = isStreaming && !!streamActivityLabel; const showHeroState = messages.length === 0 && !compact && !isSubagentMode && !loadingSession; @@ -2159,12 +2183,12 @@ export const ChatPanel = forwardRef( return (
{/* Header — sticky glass bar */}
{isSubagentMode ? ( <> @@ -2280,7 +2304,7 @@ export const ChatPanel = forwardRef( {/* File-scoped session tabs (compact mode, not in subagent mode) */} {!isSubagentMode && compact && fileContext && fileSessions.length > 0 && (
(
{/* Messages */} @@ -2444,13 +2468,25 @@ export const ChatPanel = forwardRef( userHtmlMap={userHtmlMapRef.current} /> ))} - {showInlineSpinner && ( + {showStreamActivity && (
- +
+ + + {streamActivityLabel} + +
)}
@@ -2501,7 +2537,7 @@ export const ChatPanel = forwardRef( {/* Input bar at bottom (hidden when hero state is active) */} {!showHeroState && (
diff --git a/apps/web/app/components/workspace/chat-sessions-sidebar.tsx b/apps/web/app/components/workspace/chat-sessions-sidebar.tsx index 9b63940d73e..8e1d6f12937 100644 --- a/apps/web/app/components/workspace/chat-sessions-sidebar.tsx +++ b/apps/web/app/components/workspace/chat-sessions-sidebar.tsx @@ -51,6 +51,10 @@ type ChatSessionsSidebarProps = { onDeleteSession?: (sessionId: string) => void; /** Called when the user renames a session from the sidebar menu. */ onRenameSession?: (sessionId: string, newTitle: string) => void; + /** Called when the user stops an actively running parent session. */ + onStopSession?: (sessionId: string) => void; + /** Called when the user stops an actively running subagent session. */ + onStopSubagent?: (sessionKey: string) => void; /** Called when the user clicks the collapse/hide sidebar button. */ onCollapse?: () => void; /** When true, show a loader instead of empty state (e.g. initial sessions fetch). */ @@ -149,6 +153,20 @@ function MoreHorizontalIcon() { ); } +function StopIcon() { + return ( + + ); +} + export function ChatSessionsSidebar({ sessions, activeSessionId, @@ -161,6 +179,8 @@ export function ChatSessionsSidebar({ onSelectSubagent, onDeleteSession, onRenameSession, + onStopSession, + onStopSubagent, onCollapse, mobile, onClose, @@ -286,8 +306,8 @@ export function ChatSessionsSidebar({ {group.sessions.map((session) => { const isActive = session.id === activeSessionId && !activeSubagentKey; const isHovered = session.id === hoveredId; - const showMore = isHovered; const isStreamingSession = streamingSessionIds?.has(session.id) ?? false; + const showMore = isHovered || isStreamingSession; const sessionSubagents = subagentsByParent.get(session.id); return (
)} - {onDeleteSession && ( -
+
+ {isStreamingSession && onStopSession && ( + + )} + {onDeleteSession && ( e.stopPropagation()} @@ -394,8 +429,8 @@ export function ChatSessionsSidebar({ -
- )} + )} +
{/* Subagent sub-items */} {sessionSubagents && sessionSubagents.length > 0 && ( @@ -406,38 +441,57 @@ export function ChatSessionsSidebar({ const subLabel = sa.label || sa.task; const truncated = subLabel.length > 40 ? subLabel.slice(0, 40) + "..." : subLabel; return ( - + {isSubRunning && onStopSubagent && ( +
- + + + )} +
); })}
diff --git a/apps/web/app/components/workspace/tab-bar.tsx b/apps/web/app/components/workspace/tab-bar.tsx index ff89a1c1a16..ee1556f06f9 100644 --- a/apps/web/app/components/workspace/tab-bar.tsx +++ b/apps/web/app/components/workspace/tab-bar.tsx @@ -21,6 +21,8 @@ type TabBarProps = { onCloseAll: () => void; onReorder: (fromIndex: number, toIndex: number) => void; onTogglePin: (tabId: string) => void; + liveChatTabIds?: Set; + onStopTab?: (tabId: string) => void; onNewTab?: () => void; leftContent?: React.ReactNode; rightContent?: React.ReactNode; @@ -32,10 +34,10 @@ type ContextMenuState = { y: number; } | null; -function tabToFaviconClass(tab: Tab): string | undefined { +function tabToFaviconClass(tab: Tab, isLive: boolean): string | undefined { switch (tab.type) { case "home": return "dench-favicon-home"; - case "chat": return "dench-favicon-chat"; + case "chat": return isLive ? "dench-favicon-chat-live" : "dench-favicon-chat"; case "app": return "dench-favicon-app"; case "cron": return "dench-favicon-cron"; case "object": return "dench-favicon-object"; @@ -60,6 +62,8 @@ export function TabBar({ onCloseAll, onReorder, onTogglePin, + liveChatTabIds, + onStopTab, onNewTab, leftContent, rightContent, @@ -104,10 +108,10 @@ export function TabBar({ title: tab.title, active: tab.id === activeTabId, favicon: tabToFavicon(tab), - faviconClass: tabToFaviconClass(tab), + faviconClass: tabToFaviconClass(tab, liveChatTabIds?.has(tab.id) ?? false), isCloseIconVisible: !tab.pinned, })); - }, [nonHomeTabs, activeTabId]); + }, [nonHomeTabs, activeTabId, liveChatTabIds]); const handleActive = useCallback((id: string) => onActivate(id), [onActivate]); const handleClose = useCallback((id: string) => onClose(id), [onClose]); @@ -177,6 +181,15 @@ export function TabBar({ label={contextTab.pinned ? "Unpin Tab" : "Pin Tab"} onClick={() => { onTogglePin(contextMenu.tabId); setContextMenu(null); }} /> + {contextTab.type === "chat" && liveChatTabIds?.has(contextMenu.tabId) && onStopTab && ( + <> +
+ { onStopTab(contextMenu.tabId); setContextMenu(null); }} + /> + + )}
(null); - // Chat panel ref for session management + // Visible main chat panel ref for session management const chatRef = useRef(null); + // Mounted main chat panels keyed by tab id so inactive tabs can keep streaming. + const chatPanelRefs = useRef>({}); // Compact (file-scoped) chat panel ref for sidebar drag-and-drop const compactChatRef = useRef(null); // Root layout ref for resize handle position (handle follows cursor) @@ -442,53 +464,15 @@ function WorkspacePageInner() { const [sessionsLoading, setSessionsLoading] = useState(true); const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0); const [streamingSessionIds, setStreamingSessionIds] = useState>(new Set()); + const [chatRuntimeSnapshots, setChatRuntimeSnapshots] = useState>({}); + const [chatRunsSnapshot, setChatRunsSnapshot] = useState(() => + createChatRunsSnapshot({ parentRuns: [], subagents: [] }), + ); // Subagent tracking const [subagents, setSubagents] = useState([]); const [activeSubagentKey, setActiveSubagentKey] = useState(null); - const handleSubagentSpawned = useCallback((info: SubagentSpawnInfo) => { - setSubagents((prev) => { - const idx = prev.findIndex((sa) => sa.childSessionKey === info.childSessionKey); - if (idx >= 0) { - // Update status if changed - if (prev[idx].status === info.status) {return prev;} - const updated = [...prev]; - updated[idx] = { ...prev[idx], ...info }; - return updated; - } - return [...prev, info]; - }); - }, []); - - const handleSelectSubagent = useCallback((sessionKey: string) => { - setActiveSubagentKey(sessionKey); - }, []); - - const handleBackFromSubagent = useCallback(() => { - setActiveSubagentKey(null); - }, []); - - // Navigate to a subagent panel when its card is clicked in the chat. - // The identifier may be a childSessionKey (preferred) or a task label (legacy fallback). - const handleSubagentClickFromChat = useCallback((identifier: string) => { - const byKey = subagents.find((sa) => sa.childSessionKey === identifier); - if (byKey) { - setActiveSubagentKey(byKey.childSessionKey); - return; - } - const byTask = subagents.find((sa) => sa.task === identifier); - if (byTask) { - setActiveSubagentKey(byTask.childSessionKey); - } - }, [subagents]); - - // Find the active subagent's info for the panel - const activeSubagent = useMemo(() => { - if (!activeSubagentKey) {return null;} - return subagents.find((sa) => sa.childSessionKey === activeSubagentKey) ?? null; - }, [activeSubagentKey, subagents]); - // Cron jobs state const [cronJobs, setCronJobs] = useState([]); @@ -531,15 +515,11 @@ function WorkspacePageInner() { const loaded = loadTabs(key); const hasNonHomeTabs = loaded.tabs.some((t) => t.id !== HOME_TAB_ID); if (!hasNonHomeTabs) { - const newTab: Tab = { - id: generateTabId(), - type: "chat", - title: "New Chat", - }; - setTabState(openTab(loaded, newTab)); + setTabState(openTab(loaded, createBlankChatTab())); } else { setTabState(loaded); } + setChatRuntimeSnapshots({}); }, [workspaceName]); // Persist tabs to localStorage on change (only after initial load for this workspace) @@ -549,8 +529,171 @@ function WorkspacePageInner() { saveTabs(tabState, key); }, [tabState, workspaceName]); + useEffect(() => { + const validTabIds = new Set(tabState.tabs.map((tab) => tab.id)); + setChatRuntimeSnapshots((prev) => { + let next = prev; + for (const tabId of Object.keys(prev)) { + if (!validTabIds.has(tabId)) { + next = removeChatRuntimeSnapshot(next, tabId); + } + } + return next; + }); + for (const tabId of Object.keys(chatPanelRefs.current)) { + if (!validTabIds.has(tabId)) { + delete chatPanelRefs.current[tabId]; + } + } + }, [tabState.tabs]); + // Ref for the keyboard shortcut to close the active tab (avoids stale closure over loadContent) const tabCloseActiveRef = useRef<(() => void) | null>(null); + const activeTab = useMemo( + () => tabState.tabs.find((tab) => tab.id === tabState.activeTabId) ?? HOME_TAB, + [tabState], + ); + const mainChatTabs = useMemo( + () => tabState.tabs.filter((tab) => tab.id !== HOME_TAB_ID && isChatTab(tab)), + [tabState.tabs], + ); + + const openBlankChatTab = useCallback(() => { + const tab = createBlankChatTab(); + setActivePath(null); + setContent({ kind: "none" }); + setActiveSessionId(null); + setActiveSubagentKey(null); + setTabState((prev) => openTab(prev, tab)); + return tab; + }, []); + + const openSessionChatTab = useCallback((sessionId: string, title?: string) => { + setActivePath(null); + setContent({ kind: "none" }); + setActiveSessionId(sessionId); + setActiveSubagentKey(null); + setTabState((prev) => openOrFocusParentChatTab(prev, { sessionId, title })); + }, []); + + const openSubagentChatTab = useCallback((params: { + sessionKey: string; + parentSessionId: string; + title?: string; + }) => { + setActivePath(null); + setContent({ kind: "none" }); + setActiveSessionId(params.parentSessionId); + setActiveSubagentKey(params.sessionKey); + setTabState((prev) => openOrFocusSubagentChatTab(prev, params)); + }, []); + + const visibleMainChatTabId = useMemo(() => { + if (isChatTab(activeTab)) { + return activeTab.id; + } + if (activeSubagentKey) { + const matchingSubagentTab = mainChatTabs.find((tab) => tab.sessionKey === activeSubagentKey); + if (matchingSubagentTab) { + return matchingSubagentTab.id; + } + } + if (activeSessionId) { + const matchingParentTab = mainChatTabs.find((tab) => tab.sessionId === activeSessionId); + if (matchingParentTab) { + return matchingParentTab.id; + } + } + return mainChatTabs[0]?.id ?? null; + }, [activeTab, activeSessionId, activeSubagentKey, mainChatTabs]); + + useEffect(() => { + if (!isChatTab(activeTab)) { + return; + } + const identity = resolveChatIdentityForTab(activeTab); + setActiveSessionId((prev) => prev === identity.sessionId ? prev : identity.sessionId); + setActiveSubagentKey((prev) => prev === identity.subagentKey ? prev : identity.subagentKey); + }, [activeTab]); + + const setMainChatPanelRef = useCallback((tabId: string, handle: ChatPanelHandle | null) => { + chatPanelRefs.current[tabId] = handle; + }, []); + + useEffect(() => { + chatRef.current = visibleMainChatTabId ? chatPanelRefs.current[visibleMainChatTabId] ?? null : null; + }, [visibleMainChatTabId]); + + const handleChatRuntimeStateChange = useCallback((tabId: string, runtime: ChatPanelRuntimeState) => { + setChatRuntimeSnapshots((prev) => + mergeChatRuntimeSnapshot(prev, { + tabId, + ...runtime, + }), + ); + }, []); + + const handleChatTabSessionChange = useCallback((tabId: string, sessionId: string | null) => { + setTabState((prev) => bindParentSessionToChatTab(prev, tabId, sessionId)); + if (tabState.activeTabId === tabId || visibleMainChatTabId === tabId) { + setActiveSessionId(sessionId); + setActiveSubagentKey(null); + } + }, [tabState.activeTabId, visibleMainChatTabId]); + + const sendMessageInChatTab = useCallback((tabId: string, message: string) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + void chatPanelRefs.current[tabId]?.sendNewMessage(message); + }); + }); + }, []); + + // Navigate to a subagent panel when its card is clicked in the chat. + // The identifier may be a childSessionKey (preferred) or a task label (legacy fallback). + const handleSubagentClickFromChat = useCallback((identifier: string) => { + const byKey = subagents.find((sa) => sa.childSessionKey === identifier); + if (byKey) { + openSubagentChatTab({ + sessionKey: byKey.childSessionKey, + parentSessionId: byKey.parentSessionId, + title: byKey.label || byKey.task, + }); + return; + } + const byTask = subagents.find((sa) => sa.task === identifier); + if (byTask) { + openSubagentChatTab({ + sessionKey: byTask.childSessionKey, + parentSessionId: byTask.parentSessionId, + title: byTask.label || byTask.task, + }); + } + }, [openSubagentChatTab, subagents]); + + const handleSelectSubagent = useCallback((sessionKey: string) => { + const subagent = subagents.find((entry) => entry.childSessionKey === sessionKey); + if (!subagent) { + return; + } + openSubagentChatTab({ + sessionKey, + parentSessionId: subagent.parentSessionId, + title: subagent.label || subagent.task, + }); + }, [openSubagentChatTab, subagents]); + + const handleBackFromSubagent = useCallback(() => { + if (!activeSubagentKey) { + return; + } + const activeChild = subagents.find((entry) => entry.childSessionKey === activeSubagentKey); + if (activeChild) { + openSessionChatTab(activeChild.parentSessionId); + return; + } + setActiveSubagentKey(null); + }, [activeSubagentKey, openSessionChatTab, subagents]); const openTabForNode = useCallback((node: { path: string; name: string; type: string }) => { const tab: Tab = { @@ -700,7 +843,12 @@ function WorkspacePageInner() { setActiveSessionId, setActiveSubagentKey, resetMainChat: () => { - void chatRef.current?.newSession(); + chatPanelRefs.current = {}; + setChatRuntimeSnapshots({}); + setChatRunsSnapshot(createChatRunsSnapshot({ parentRuns: [], subagents: [] })); + setStreamingSessionIds(new Set()); + setSubagents([]); + setTabState({ tabs: [HOME_TAB], activeTabId: HOME_TAB_ID }); }, replaceUrlToRoot: () => { // URL sync effect will write the correct URL after state is cleared @@ -717,21 +865,37 @@ function WorkspacePageInner() { async (sessionId: string) => { const res = await fetch(`/api/web-sessions/${sessionId}`, { method: "DELETE" }); if (!res.ok) {return;} + const closedTabIds = new Set( + tabState.tabs + .filter((tab) => tab.type === "chat" && (tab.sessionId === sessionId || tab.parentSessionId === sessionId)) + .map((tab) => tab.id), + ); + setTabState((prev) => { + let next = closeChatTabsForSession(prev, sessionId); + const hasNonHomeTabs = next.tabs.some((tab) => tab.id !== HOME_TAB_ID); + if (!hasNonHomeTabs) { + next = openTab(next, createBlankChatTab()); + } + return next; + }); + setChatRuntimeSnapshots((prev) => { + let next = prev; + for (const tabId of closedTabIds) { + next = removeChatRuntimeSnapshot(next, tabId); + } + return next; + }); if (activeSessionId === sessionId) { - setActiveSessionId(null); - setActiveSubagentKey(null); const remaining = sessions.filter((s) => s.id !== sessionId); if (remaining.length > 0) { - const next = remaining[0]; - setActiveSessionId(next.id); - void chatRef.current?.loadSession(next.id); + openSessionChatTab(remaining[0].id, remaining[0].title); } else { - void chatRef.current?.newSession(); + openBlankChatTab(); } } void fetchSessions(); }, - [activeSessionId, sessions, fetchSessions], + [activeSessionId, sessions, fetchSessions, openBlankChatTab, openSessionChatTab, tabState.tabs], ); const handleRenameSession = useCallback( @@ -746,15 +910,28 @@ function WorkspacePageInner() { [fetchSessions], ); - // Poll for active (streaming) agent runs so the sidebar can show indicators. + // Poll for parent/subagent run state so tabs and sidebars can reflect + // background activity across all open chats. useEffect(() => { let cancelled = false; const poll = async () => { try { - const res = await fetch("/api/chat/active"); + const res = await fetch("/api/chat/runs"); if (cancelled) {return;} const data = await res.json(); - const ids: string[] = data.sessionIds ?? []; + const parentRuns: Array<{ sessionId: string; status: "running" | "waiting-for-subagents" | "completed" | "error" }> = data.parentRuns ?? []; + const nextSubagents: SubagentSpawnInfo[] = data.subagents ?? []; + const ids = parentRuns + .filter((run) => run.status === "running" || run.status === "waiting-for-subagents") + .map((run) => run.sessionId); + setChatRunsSnapshot(createChatRunsSnapshot({ + parentRuns, + subagents: nextSubagents.map((subagent) => ({ + childSessionKey: subagent.childSessionKey, + status: subagent.status ?? "completed", + })), + })); + setSubagents(nextSubagents); setStreamingSessionIds((prev) => { // Only update state if the set actually changed (avoid re-renders). if (prev.size === ids.length && ids.every((id) => prev.has(id))) {return prev;} @@ -924,7 +1101,7 @@ function WorkspacePageInner() { setBrowseDir(null); setActivePath(null); setContent({ kind: "none" }); - void chatRef.current?.newSession(); + openBlankChatTab(); return; } } @@ -944,18 +1121,12 @@ function WorkspacePageInner() { // Intercept chat folder item clicks if (node.path.startsWith("~chats/")) { const sessionId = node.path.slice("~chats/".length); - setActivePath(null); - setContent({ kind: "none" }); - setActiveSessionId(sessionId); - void chatRef.current?.loadSession(sessionId); - // URL is synced by the activeSessionId effect + openSessionChatTab(sessionId); return; } // Clicking the Chats folder itself opens a new chat if (node.path === "~chats") { - setActivePath(null); - setContent({ kind: "none" }); - void chatRef.current?.newSession(); + openBlankChatTab(); return; } // Intercept cron job item clicks @@ -977,15 +1148,44 @@ function WorkspacePageInner() { openTabForNode(node); void loadContent(node); }, - [loadContent, openTabForNode, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir], + [loadContent, openBlankChatTab, openSessionChatTab, openTabForNode, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir], ); + const applyActivatedTab = useCallback((tab: Tab | undefined) => { + if (!tab || tab.id === HOME_TAB_ID) { + setActivePath(null); + setContent({ kind: "none" }); + return; + } + if (tab.type === "chat") { + setActivePath(null); + setContent({ kind: "none" }); + const identity = resolveChatIdentityForTab(tab); + setActiveSessionId(identity.sessionId); + setActiveSubagentKey(identity.subagentKey); + return; + } + if (tab.path) { + const node = resolveNode(tree, tab.path); + if (node) { + void loadContent(node); + } else if (tab.path === "~cron") { + setActivePath("~cron"); + setContent({ kind: "cron-dashboard" }); + } else if (tab.path.startsWith("~cron/")) { + setActivePath(tab.path); + const jobId = tab.path.slice("~cron/".length); + const job = cronJobs.find((j) => j.id === jobId); + if (job) setContent({ kind: "cron-job", jobId, job }); + } + } + }, [tree, loadContent, cronJobs]); + // Tab handler callbacks (defined after loadContent is available) const handleTabActivate = useCallback((tabId: string) => { if (tabId === HOME_TAB_ID) { - setActivePath(null); - setContent({ kind: "none" }); setTabState((prev) => activateTab(prev, tabId)); + applyActivatedTab(undefined); return; } let tab: Tab | undefined; @@ -995,81 +1195,36 @@ function WorkspacePageInner() { return next; }); requestAnimationFrame(() => { - if (!tab) return; - if (tab.type === "chat") { - setActivePath(null); - setContent({ kind: "none" }); - if (tab.sessionId) { - setActiveSessionId(tab.sessionId); - setActiveSubagentKey(null); - void chatRef.current?.loadSession(tab.sessionId); - } else { - setActiveSessionId(null); - setActiveSubagentKey(null); - void chatRef.current?.newSession(); - } - } else if (tab.path) { - const node = resolveNode(tree, tab.path); - if (node) { - void loadContent(node); - } else if (tab.path === "~cron") { - setActivePath("~cron"); - setContent({ kind: "cron-dashboard" }); - } else if (tab.path.startsWith("~cron/")) { - setActivePath(tab.path); - const jobId = tab.path.slice("~cron/".length); - const job = cronJobs.find((j) => j.id === jobId); - if (job) setContent({ kind: "cron-job", jobId, job }); - } - } + applyActivatedTab(tab); }); - }, [tree, loadContent, cronJobs]); + }, [applyActivatedTab]); const handleTabClose = useCallback((tabId: string) => { const prev = tabState; let next = closeTab(prev, tabId); const hasNonHomeTabs = next.tabs.some((t) => t.id !== HOME_TAB_ID); if (!hasNonHomeTabs) { - const newTab: Tab = { - id: generateTabId(), - type: "chat", - title: "New Chat", - }; - next = openTab(next, newTab); + next = openTab(next, createBlankChatTab()); setTabState(next); setActivePath(null); setContent({ kind: "none" }); setActiveSessionId(null); setActiveSubagentKey(null); - requestAnimationFrame(() => { - void chatRef.current?.newSession(); - }); return; } setTabState(next); if (next.activeTabId !== prev.activeTabId) { const newActive = next.tabs.find((t) => t.id === next.activeTabId); if (!newActive || newActive.id === HOME_TAB_ID) { - setActivePath(null); - setContent({ kind: "none" }); - } else if (newActive.type === "chat") { - setActivePath(null); - setContent({ kind: "none" }); - if (newActive.sessionId) { - setActiveSessionId(newActive.sessionId); - setActiveSubagentKey(null); - requestAnimationFrame(() => { - void chatRef.current?.loadSession(newActive.sessionId!); - }); - } - } else if (newActive.path) { - const node = resolveNode(tree, newActive.path); - if (node) { - void loadContent(node); - } + const identity = resolveChatIdentityForTab(next.tabs.find((tab) => tab.type === "chat")); + setActiveSessionId(identity.sessionId); + setActiveSubagentKey(identity.subagentKey); + applyActivatedTab(undefined); + } else { + applyActivatedTab(newActive); } } - }, [tree, loadContent, tabState]); + }, [applyActivatedTab, tabState]); // Keep ref in sync so keyboard shortcut can close active tab useEffect(() => { @@ -1081,12 +1236,16 @@ function WorkspacePageInner() { }, [tabState.activeTabId, handleTabClose]); const handleTabCloseOthers = useCallback((tabId: string) => { - setTabState((prev) => closeOtherTabs(prev, tabId)); - }, []); + const next = closeOtherTabs(tabState, tabId); + setTabState(next); + applyActivatedTab(next.tabs.find((tab) => tab.id === next.activeTabId)); + }, [applyActivatedTab, tabState]); const handleTabCloseToRight = useCallback((tabId: string) => { - setTabState((prev) => closeTabsToRight(prev, tabId)); - }, []); + const next = closeTabsToRight(tabState, tabId); + setTabState(next); + applyActivatedTab(next.tabs.find((tab) => tab.id === next.activeTabId)); + }, [applyActivatedTab, tabState]); const handleTabCloseAll = useCallback(() => { setTabState((prev) => { @@ -1095,15 +1254,7 @@ function WorkspacePageInner() { setContent({ kind: "none" }); setActiveSessionId(null); setActiveSubagentKey(null); - const newTab: Tab = { - id: generateTabId(), - type: "chat", - title: "New Chat", - }; - return openTab(closed, newTab); - }); - requestAnimationFrame(() => { - void chatRef.current?.newSession(); + return openTab(closed, createBlankChatTab()); }); }, []); @@ -1185,50 +1336,14 @@ function WorkspacePageInner() { [], ); - // Open inline file-path mentions from chat. - // In chat mode, render a Dropbox-style preview in the right sidebar. + // Open inline file-path mentions from chat in a new workspace tab. const handleFilePathClickFromChat = useCallback( async (rawPath: string) => { const inputPath = normalizeChatPath(rawPath); if (!inputPath) {return false;} - // Desktop behavior: always use right-sidebar preview for chat path clicks. - const shouldPreviewInSidebar = !isMobile; - - const openNode = async (node: TreeNode) => { - if (!shouldPreviewInSidebar) { - handleNodeSelect(node); - setShowChatSidebar(true); - return true; - } - - // Ensure we are in main-chat layout so the preview panel is visible. - if (activePath || content.kind !== "none") { - setActivePath(null); - setContent({ kind: "none" }); - } - - setChatSidebarPreview({ - status: "loading", - path: node.path, - filename: node.name, - }); - const previewContent = await loadSidebarPreviewFromNode(node); - if (!previewContent) { - setChatSidebarPreview({ - status: "error", - path: node.path, - filename: node.name, - message: "Could not preview this file.", - }); - return false; - } - setChatSidebarPreview({ - status: "ready", - path: node.path, - filename: node.name, - content: previewContent, - }); + const openNode = (node: TreeNode) => { + handleNodeSelect(node); return true; }; @@ -1241,7 +1356,7 @@ function WorkspacePageInner() { ) { const node = resolveNode(tree, inputPath); if (node) { - return await openNode(node); + return openNode(node); } } @@ -1262,24 +1377,14 @@ function WorkspacePageInner() { if (relPath) { const node = resolveNode(tree, relPath); if (node) { - return await openNode(node); + return openNode(node); } } } if (info.type === "directory") { const dirNode: TreeNode = { name: info.name, path: info.path, type: "folder" }; - if (shouldPreviewInSidebar) { - return await openNode(dirNode); - } - setBrowseDir(info.path); - setActivePath(info.path); - setContent({ - kind: "directory", - node: { name: info.name, path: info.path, type: "folder" }, - }); - setShowChatSidebar(true); - return true; + return openNode(dirNode); } if (info.type === "file") { @@ -1288,16 +1393,7 @@ function WorkspacePageInner() { path: info.path, type: inferNodeTypeFromFileName(info.name), }; - if (shouldPreviewInSidebar) { - return await openNode(fileNode); - } - const parentDir = info.path.split("/").slice(0, -1).join("/") || "/"; - if (isAbsolutePath(info.path)) { - setBrowseDir(parentDir); - } - await loadContent(fileNode); - setShowChatSidebar(true); - return true; + return openNode(fileNode); } } catch { // Ignore -- chat message bubble shows inline error state. @@ -1305,7 +1401,7 @@ function WorkspacePageInner() { return false; }, - [activePath, content.kind, isMobile, tree, handleNodeSelect, workspaceRoot, loadSidebarPreviewFromNode, setBrowseDir, loadContent, router], + [tree, handleNodeSelect, workspaceRoot], ); // Build the enhanced tree: real tree + Cron virtual folder at the bottom @@ -1532,13 +1628,14 @@ function WorkspacePageInner() { } } else if (urlState.chat) { initialPathHandled.current = true; - setActiveSessionId(urlState.chat); - setActivePath(null); - setContent({ kind: "none" }); - void chatRef.current?.loadSession(urlState.chat); - if (urlState.subagent) { - setActiveSubagentKey(urlState.subagent); + openSubagentChatTab({ + sessionKey: urlState.subagent, + parentSessionId: urlState.chat, + title: "Subagent", + }); + } else { + openSessionChatTab(urlState.chat); } } else { // No path or chat param — mark hydration done (bare / or browse-only) @@ -1611,12 +1708,15 @@ function WorkspacePageInner() { } setFileChatSessionId(urlState.fileChat); } else if (urlState.chat) { - setActiveSessionId(urlState.chat); - setActivePath(null); - setContent({ kind: "none" }); - void chatRef.current?.loadSession(urlState.chat); - setActiveSubagentKey(urlState.subagent); - setTabState((prev) => activateTab(prev, HOME_TAB_ID)); + if (urlState.subagent) { + openSubagentChatTab({ + sessionKey: urlState.subagent, + parentSessionId: urlState.chat, + title: "Subagent", + }); + } else { + openSessionChatTab(urlState.chat); + } } else { setActivePath(null); setContent({ kind: "none" }); @@ -1693,11 +1793,9 @@ function WorkspacePageInner() { setActivePath(null); setContent({ kind: "none" }); - // Give ChatPanel a frame to mount, then send the message - requestAnimationFrame(() => { - void chatRef.current?.sendNewMessage(sendParam); - }); - }, [searchParams, router]); + const tab = openBlankChatTab(); + sendMessageInChatTab(tab.id, sendParam); + }, [openBlankChatTab, searchParams, router, sendMessageInChatTab]); const handleBreadcrumbNavigate = useCallback( (path: string) => { @@ -1844,10 +1942,9 @@ function WorkspacePageInner() { const handleCronSendCommand = useCallback((message: string) => { setActivePath(null); setContent({ kind: "none" }); - requestAnimationFrame(() => { - void chatRef.current?.sendNewMessage(message); - }); - }, []); + const tab = openBlankChatTab(); + sendMessageInChatTab(tab.id, message); + }, [openBlankChatTab, sendMessageInChatTab]); // Derive the active session's title for the header / right sidebar const activeSessionTitle = useMemo(() => { @@ -1857,18 +1954,128 @@ function WorkspacePageInner() { }, [activeSessionId, sessions]); useEffect(() => { - if (!activeSessionTitle) return; setTabState((prev) => { - const active = prev.tabs.find((t) => t.id === prev.activeTabId); - if (active?.type === "chat" && active.title !== activeSessionTitle) { - return updateTabTitle(prev, active.id, activeSessionTitle); + let next = syncParentChatTabTitles(prev, sessions); + next = syncSubagentChatTabTitles(next, subagents); + if (!activeSessionTitle) { + return next; } - return prev; + const active = next.tabs.find((t) => t.id === next.activeTabId); + if (active?.type === "chat" && active.title !== activeSessionTitle && !active.sessionKey) { + return updateChatTabTitle(next, active.id, activeSessionTitle); + } + return next; }); - }, [activeSessionTitle]); + }, [activeSessionTitle, sessions, subagents]); - // Whether to show the main ChatPanel (no file/content selected) - const showMainChat = !activePath || content.kind === "none"; + const runningSubagentKeys = useMemo( + () => new Set(subagents.filter((subagent) => subagent.status === "running").map((subagent) => subagent.childSessionKey)), + [subagents], + ); + + const liveChatTabIds = useMemo(() => { + const ids = new Set(); + for (const tab of mainChatTabs) { + const runtime = chatRuntimeSnapshots[tab.id]; + if (runtime?.isStreaming) { + ids.add(tab.id); + continue; + } + if (tab.sessionKey && (runningSubagentKeys.has(tab.sessionKey) || chatRunsSnapshot.subagentStatuses.get(tab.sessionKey) === "running")) { + ids.add(tab.id); + continue; + } + if (tab.sessionId && streamingSessionIds.has(tab.sessionId)) { + ids.add(tab.id); + } + } + return ids; + }, [chatRunsSnapshot.subagentStatuses, chatRuntimeSnapshots, mainChatTabs, runningSubagentKeys, streamingSessionIds]); + + const optimisticallyStopParentSession = useCallback((sessionId: string) => { + setStreamingSessionIds((prev) => { + if (!prev.has(sessionId)) { + return prev; + } + const next = new Set(prev); + next.delete(sessionId); + return next; + }); + setSubagents((prev) => prev.map((subagent) => + subagent.parentSessionId === sessionId && subagent.status === "running" + ? { ...subagent, status: "completed" } + : subagent, + )); + setChatRuntimeSnapshots((prev) => { + const next: Record = {}; + for (const [tabId, snapshot] of Object.entries(prev)) { + next[tabId] = snapshot.sessionId === sessionId + ? { ...snapshot, isStreaming: false, isReconnecting: false, status: "ready" } + : snapshot; + } + return next; + }); + }, []); + + const optimisticallyStopSubagent = useCallback((sessionKey: string) => { + setSubagents((prev) => prev.map((subagent) => + subagent.childSessionKey === sessionKey && subagent.status === "running" + ? { ...subagent, status: "completed" } + : subagent, + )); + setChatRuntimeSnapshots((prev) => { + const next: Record = {}; + for (const [tabId, snapshot] of Object.entries(prev)) { + next[tabId] = snapshot.sessionKey === sessionKey + ? { ...snapshot, isStreaming: false, isReconnecting: false, status: "ready" } + : snapshot; + } + return next; + }); + }, []); + + const stopParentSession = useCallback(async (sessionId: string) => { + optimisticallyStopParentSession(sessionId); + try { + await fetch("/api/chat/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId, cascadeChildren: true }), + }); + } catch { + // Best-effort optimistic stop; polling will reconcile state. + } + }, [optimisticallyStopParentSession]); + + const stopSubagentSession = useCallback(async (sessionKey: string) => { + optimisticallyStopSubagent(sessionKey); + try { + await fetch("/api/chat/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionKey }), + }); + } catch { + // Best-effort optimistic stop; polling will reconcile state. + } + }, [optimisticallyStopSubagent]); + + const handleStopChatTab = useCallback((tabId: string) => { + const tab = tabState.tabs.find((entry) => entry.id === tabId); + if (!tab || tab.type !== "chat") { + return; + } + if (tab.sessionKey) { + void stopSubagentSession(tab.sessionKey); + return; + } + if (tab.sessionId) { + void stopParentSession(tab.sessionId); + } + }, [stopParentSession, stopSubagentSession, tabState.tabs]); + + // Whether to show the main chat workspace instead of file/object content. + const showMainChat = activeTab.type === "chat" || activeTab.id === HOME_TAB_ID || (!activePath || content.kind === "none"); return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions @@ -1908,15 +2115,12 @@ function WorkspacePageInner() { chatActiveSubagentKey={activeSubagentKey} chatSessionsLoading={sessionsLoading} onSelectChatSession={(sessionId) => { - setActiveSessionId(sessionId); - setActiveSubagentKey(null); - void chatRef.current?.loadSession(sessionId); + const session = sessions.find((entry) => entry.id === sessionId); + openSessionChatTab(sessionId, session?.title); setSidebarOpen(false); }} onNewChatSession={() => { - setActiveSessionId(null); - setActiveSubagentKey(null); - void chatRef.current?.newSession(); + openBlankChatTab(); setSidebarOpen(false); }} onSelectChatSubagent={handleSelectSubagent} @@ -1974,14 +2178,11 @@ function WorkspacePageInner() { chatActiveSubagentKey={activeSubagentKey} chatSessionsLoading={sessionsLoading} onSelectChatSession={(sessionId) => { - setActiveSessionId(sessionId); - setActiveSubagentKey(null); - void chatRef.current?.loadSession(sessionId); + const session = sessions.find((entry) => entry.id === sessionId); + openSessionChatTab(sessionId, session?.title); }} onNewChatSession={() => { - setActiveSessionId(null); - setActiveSubagentKey(null); - void chatRef.current?.newSession(); + openBlankChatTab(); }} onSelectChatSubagent={handleSelectSubagent} onDeleteChatSession={handleDeleteSession} @@ -1997,7 +2198,7 @@ function WorkspacePageInner() { {/* Main content */}
-
+
{/* Mobile top bar — always visible on mobile */} {isMobile && (
{ - const newTab: Tab = { - id: generateTabId(), - type: "chat", - title: "New Chat", - }; - setActivePath(null); - setContent({ kind: "none" }); - setActiveSessionId(null); - setActiveSubagentKey(null); - setTabState((prev) => openTab(prev, newTab)); - requestAnimationFrame(() => { - void chatRef.current?.newSession(); - }); - }} + liveChatTabIds={liveChatTabIds} + onStopTab={handleStopChatTab} + onNewTab={openBlankChatTab} rightContent={showMainChat ? ( <> + {visibleMainChatTabId && liveChatTabIds.has(visibleMainChatTabId) && ( + + )} - {activeSessionId && ( + {activeSessionId && !activeSubagentKey && ( - {showMainChat ? ( -
- { - setActiveSessionId(id); - setActiveSubagentKey(null); - if (id) { - setTabState((prev) => { - const active = prev.tabs.find((t) => t.id === prev.activeTabId); - if (active?.type === "chat" && !active.sessionId) { - return { - ...prev, - tabs: prev.tabs.map((t) => - t.id === active.id ? { ...t, sessionId: id } : t, - ), - }; - } - return prev; - }); - } - }} - onSessionsChange={activeSubagent ? undefined : refreshSessions} - onSubagentSpawned={activeSubagent ? undefined : handleSubagentSpawned} - onSubagentClick={handleSubagentClickFromChat} - onFilePathClick={handleFilePathClickFromChat} - onDeleteSession={activeSubagent ? undefined : handleDeleteSession} - onRenameSession={activeSubagent ? undefined : handleRenameSession} - compact={isMobile} - sessionKey={activeSubagent?.childSessionKey} - subagentTask={activeSubagent?.task} - subagentLabel={activeSubagent?.label} - onBack={activeSubagent ? handleBackFromSubagent : undefined} - hideHeaderActions={!isMobile} - /> -
- ) : ( +
+ {mainChatTabs.map((tab) => { + const subagent = tab.sessionKey + ? subagents.find((entry) => entry.childSessionKey === tab.sessionKey) + : null; + const isVisible = tab.id === visibleMainChatTabId; + return ( +
+ setMainChatPanelRef(tab.id, handle)} + sessionTitle={tab.title} + initialSessionId={tab.sessionKey ? undefined : tab.sessionId ?? undefined} + onActiveSessionChange={tab.sessionKey ? undefined : (id) => handleChatTabSessionChange(tab.id, id)} + onSessionsChange={refreshSessions} + onSubagentClick={handleSubagentClickFromChat} + onFilePathClick={handleFilePathClickFromChat} + onDeleteSession={tab.sessionKey ? undefined : handleDeleteSession} + onRenameSession={tab.sessionKey ? undefined : handleRenameSession} + compact={isMobile} + sessionKey={tab.sessionKey ?? undefined} + subagentTask={subagent?.task} + subagentLabel={subagent?.label} + onBack={tab.sessionKey ? handleBackFromSubagent : undefined} + hideHeaderActions={!isMobile} + onRuntimeStateChange={(runtime) => handleChatRuntimeStateChange(tab.id, runtime)} + /> +
+ ); + })} +
+ {!showMainChat && (
-
+
{ - setActiveSessionId(sessionId); - setActiveSubagentKey(null); - void chatRef.current?.loadSession(sessionId); + const session = sessions.find((entry) => entry.id === sessionId); + openSessionChatTab(sessionId, session?.title); }} onNewSession={() => { - setActiveSessionId(null); - setActiveSubagentKey(null); - void chatRef.current?.newSession(); + openBlankChatTab(); }} onSelectSubagent={handleSelectSubagent} onDeleteSession={handleDeleteSession} onRenameSession={handleRenameSession} + onStopSession={(sessionId) => { void stopParentSession(sessionId); }} + onStopSubagent={(sessionKey) => { void stopSubagentSession(sessionKey); }} embedded />
@@ -2304,7 +2502,7 @@ function WorkspacePageInner() { transition: "width 200ms ease", }} > -
+
; + subagentStatuses: Map; +}; + +export function mergeChatRuntimeSnapshot( + state: Record, + snapshot: ChatTabRuntimeSnapshot, +): Record { + const current = state[snapshot.tabId]; + if ( + current && + current.sessionId === snapshot.sessionId && + current.sessionKey === snapshot.sessionKey && + current.isStreaming === snapshot.isStreaming && + current.status === snapshot.status && + current.isReconnecting === snapshot.isReconnecting && + current.loadingSession === snapshot.loadingSession + ) { + return state; + } + return { + ...state, + [snapshot.tabId]: snapshot, + }; +} + +export function removeChatRuntimeSnapshot( + state: Record, + tabId: string, +): Record { + if (!(tabId in state)) { + return state; + } + const next = { ...state }; + delete next[tabId]; + return next; +} + +export function createChatRunsSnapshot(params: { + parentRuns: Array<{ sessionId: string; status: "running" | "waiting-for-subagents" | "completed" | "error" }>; + subagents: Array<{ childSessionKey: string; status: "running" | "completed" | "error" }>; +}): ChatRunsSnapshot { + return { + parentStatuses: new Map(params.parentRuns.map((run) => [run.sessionId, run.status])), + subagentStatuses: new Map(params.subagents.map((run) => [run.childSessionKey, run.status])), + }; +} diff --git a/apps/web/lib/chat-tabs.test.ts b/apps/web/lib/chat-tabs.test.ts new file mode 100644 index 00000000000..73da8ac8d1a --- /dev/null +++ b/apps/web/lib/chat-tabs.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { HOME_TAB, openTab, type TabState } from "./tab-state"; +import { + bindParentSessionToChatTab, + closeChatTabsForSession, + createBlankChatTab, + createParentChatTab, + createSubagentChatTab, + openOrFocusParentChatTab, + openOrFocusSubagentChatTab, + resolveChatIdentityForTab, + syncParentChatTabTitles, + syncSubagentChatTabTitles, +} from "./chat-tabs"; + +function baseState(): TabState { + return { + tabs: [HOME_TAB], + activeTabId: HOME_TAB.id, + }; +} + +describe("chat tab helpers", () => { + it("reuses an existing parent chat tab for the same session (prevents duplicate live tabs)", () => { + const existing = createParentChatTab({ sessionId: "parent-1", title: "Parent" }); + const state = openTab(baseState(), existing); + + const next = openOrFocusParentChatTab(state, { sessionId: "parent-1", title: "Renamed" }); + + expect(next.tabs.filter((tab) => tab.type === "chat")).toHaveLength(1); + expect(next.activeTabId).toBe(existing.id); + }); + + it("reuses an existing subagent tab for the same child session key (prevents duplicate child viewers)", () => { + const existing = createSubagentChatTab({ + sessionKey: "agent:child-1:subagent:abc", + parentSessionId: "parent-1", + title: "Child", + }); + const state = openTab(baseState(), existing); + + const next = openOrFocusSubagentChatTab(state, { + sessionKey: "agent:child-1:subagent:abc", + parentSessionId: "parent-1", + title: "Child updated", + }); + + expect(next.tabs.filter((tab) => tab.type === "chat")).toHaveLength(1); + expect(next.activeTabId).toBe(existing.id); + }); + + it("binds a newly-created parent session id onto a draft chat tab without disturbing sibling tabs", () => { + const draft = createBlankChatTab(); + const sibling = createParentChatTab({ sessionId: "existing-1", title: "Existing" }); + const state = { + tabs: [HOME_TAB, draft, sibling], + activeTabId: draft.id, + } satisfies TabState; + + const next = bindParentSessionToChatTab(state, draft.id, "new-session-1"); + + expect(next.tabs.find((tab) => tab.id === draft.id)?.sessionId).toBe("new-session-1"); + expect(next.tabs.find((tab) => tab.id === sibling.id)?.sessionId).toBe("existing-1"); + }); + + it("closes a deleted parent session and all of its subagent tabs (prevents orphan child tabs)", () => { + const parent = createParentChatTab({ sessionId: "parent-1", title: "Parent" }); + const child = createSubagentChatTab({ + sessionKey: "agent:child-1:subagent:abc", + parentSessionId: "parent-1", + title: "Child", + }); + const unrelated = createParentChatTab({ sessionId: "parent-2", title: "Other" }); + const state = { + tabs: [HOME_TAB, parent, child, unrelated], + activeTabId: child.id, + } satisfies TabState; + + const next = closeChatTabsForSession(state, "parent-1"); + + expect(next.tabs.map((tab) => tab.id)).not.toContain(parent.id); + expect(next.tabs.map((tab) => tab.id)).not.toContain(child.id); + expect(next.tabs.map((tab) => tab.id)).toContain(unrelated.id); + }); + + it("syncs parent and subagent titles from persisted session metadata", () => { + const parent = createParentChatTab({ sessionId: "parent-1", title: "Draft title" }); + const child = createSubagentChatTab({ + sessionKey: "agent:child-1:subagent:abc", + parentSessionId: "parent-1", + title: "Child draft", + }); + const state = { + tabs: [HOME_TAB, parent, child], + activeTabId: parent.id, + } satisfies TabState; + + const parentSynced = syncParentChatTabTitles(state, [{ id: "parent-1", title: "Real title" }]); + const fullySynced = syncSubagentChatTabTitles(parentSynced, [ + { childSessionKey: "agent:child-1:subagent:abc", task: "Long task", label: "Research branch" }, + ]); + + expect(fullySynced.tabs.find((tab) => tab.id === parent.id)?.title).toBe("Real title"); + expect(fullySynced.tabs.find((tab) => tab.id === child.id)?.title).toBe("Research branch"); + }); + + it("resolves chat identity for parent and subagent tabs", () => { + const parent = createParentChatTab({ sessionId: "parent-1", title: "Parent" }); + const child = createSubagentChatTab({ + sessionKey: "agent:child-1:subagent:abc", + parentSessionId: "parent-1", + title: "Child", + }); + + expect(resolveChatIdentityForTab(parent)).toEqual({ + sessionId: "parent-1", + subagentKey: null, + }); + expect(resolveChatIdentityForTab(child)).toEqual({ + sessionId: "parent-1", + subagentKey: "agent:child-1:subagent:abc", + }); + }); +}); diff --git a/apps/web/lib/chat-tabs.ts b/apps/web/lib/chat-tabs.ts new file mode 100644 index 00000000000..00e9074de1d --- /dev/null +++ b/apps/web/lib/chat-tabs.ts @@ -0,0 +1,178 @@ +import { + type Tab, + type TabState, + generateTabId, + openTab, +} from "./tab-state"; + +export function isChatTab(tab: Tab | undefined | null): tab is Tab { + return tab?.type === "chat"; +} + +export function isSubagentChatTab(tab: Tab | undefined | null): tab is Tab { + return Boolean(tab?.type === "chat" && tab.sessionKey); +} + +export function createBlankChatTab(title = "New Chat"): Tab { + return { + id: generateTabId(), + type: "chat", + title, + }; +} + +export function createParentChatTab(params: { + sessionId: string; + title?: string; +}): Tab { + return { + id: generateTabId(), + type: "chat", + title: params.title || "New Chat", + sessionId: params.sessionId, + }; +} + +export function createSubagentChatTab(params: { + sessionKey: string; + parentSessionId: string; + title?: string; +}): Tab { + return { + id: generateTabId(), + type: "chat", + title: params.title || "Subagent", + sessionKey: params.sessionKey, + parentSessionId: params.parentSessionId, + }; +} + +export function bindParentSessionToChatTab( + state: TabState, + tabId: string, + sessionId: string | null, +): TabState { + return { + ...state, + tabs: state.tabs.map((tab) => + tab.id === tabId + ? { + ...tab, + sessionId: sessionId ?? undefined, + sessionKey: undefined, + } + : tab, + ), + }; +} + +export function updateChatTabTitle( + state: TabState, + tabId: string, + title: string, +): TabState { + return { + ...state, + tabs: state.tabs.map((tab) => + tab.id === tabId && tab.title !== title + ? { ...tab, title } + : tab, + ), + }; +} + +export function syncParentChatTabTitles( + state: TabState, + sessions: Array<{ id: string; title: string }>, +): TabState { + const titleBySessionId = new Map(sessions.map((session) => [session.id, session.title])); + let changed = false; + const tabs = state.tabs.map((tab) => { + if (tab.type !== "chat" || !tab.sessionId) { + return tab; + } + const nextTitle = titleBySessionId.get(tab.sessionId); + if (!nextTitle || nextTitle === tab.title) { + return tab; + } + changed = true; + return { ...tab, title: nextTitle }; + }); + return changed ? { ...state, tabs } : state; +} + +export function syncSubagentChatTabTitles( + state: TabState, + subagents: Array<{ childSessionKey: string; label?: string; task: string }>, +): TabState { + const titleBySessionKey = new Map( + subagents.map((subagent) => [subagent.childSessionKey, subagent.label || subagent.task]), + ); + let changed = false; + const tabs = state.tabs.map((tab) => { + if (tab.type !== "chat" || !tab.sessionKey) { + return tab; + } + const nextTitle = titleBySessionKey.get(tab.sessionKey); + if (!nextTitle || nextTitle === tab.title) { + return tab; + } + changed = true; + return { ...tab, title: nextTitle }; + }); + return changed ? { ...state, tabs } : state; +} + +export function openOrFocusParentChatTab( + state: TabState, + params: { sessionId: string; title?: string }, +): TabState { + return openTab(state, createParentChatTab(params)); +} + +export function openOrFocusSubagentChatTab( + state: TabState, + params: { sessionKey: string; parentSessionId: string; title?: string }, +): TabState { + return openTab(state, createSubagentChatTab(params)); +} + +export function closeChatTabsForSession( + state: TabState, + sessionId: string, +): TabState { + const tabs = state.tabs.filter((tab) => { + if (tab.pinned) { + return true; + } + if (tab.type !== "chat") { + return true; + } + return tab.sessionId !== sessionId && tab.parentSessionId !== sessionId; + }); + + const activeStillExists = tabs.some((tab) => tab.id === state.activeTabId); + return { + tabs, + activeTabId: activeStillExists ? state.activeTabId : tabs[tabs.length - 1]?.id ?? null, + }; +} + +export function resolveChatIdentityForTab(tab: Tab | undefined | null): { + sessionId: string | null; + subagentKey: string | null; +} { + if (!tab || tab.type !== "chat") { + return { sessionId: null, subagentKey: null }; + } + if (tab.sessionKey) { + return { + sessionId: tab.parentSessionId ?? null, + subagentKey: tab.sessionKey, + }; + } + return { + sessionId: tab.sessionId ?? null, + subagentKey: null, + }; +} diff --git a/apps/web/lib/tab-state.ts b/apps/web/lib/tab-state.ts index 36c0416ad77..dc60d268aa3 100644 --- a/apps/web/lib/tab-state.ts +++ b/apps/web/lib/tab-state.ts @@ -23,6 +23,8 @@ export type Tab = { icon?: string; path?: string; sessionId?: string; + sessionKey?: string; + parentSessionId?: string; pinned?: boolean; }; @@ -72,8 +74,8 @@ export function saveTabs(state: TabState, workspaceId?: string | null): void { if (typeof window === "undefined") return; try { const serializable: TabState = { - tabs: state.tabs.map(({ id, type, title, icon, path, sessionId, pinned }) => ({ - id, type, title, icon, path, sessionId, pinned, + tabs: state.tabs.map(({ id, type, title, icon, path, sessionId, sessionKey, parentSessionId, pinned }) => ({ + id, type, title, icon, path, sessionId, sessionKey, parentSessionId, pinned, })), activeTabId: state.activeTabId, }; @@ -91,12 +93,18 @@ export function findTabBySessionId(tabs: Tab[], sessionId: string): Tab | undefi return tabs.find((t) => t.type === "chat" && t.sessionId === sessionId); } +export function findTabBySessionKey(tabs: Tab[], sessionKey: string): Tab | undefined { + return tabs.find((t) => t.type === "chat" && t.sessionKey === sessionKey); +} + export function openTab(state: TabState, tab: Tab): TabState { const existing = tab.path ? findTabByPath(state.tabs, tab.path) - : tab.sessionId - ? findTabBySessionId(state.tabs, tab.sessionId) - : undefined; + : tab.sessionKey + ? findTabBySessionKey(state.tabs, tab.sessionKey) + : tab.sessionId + ? findTabBySessionId(state.tabs, tab.sessionId) + : undefined; if (existing) { return { ...state, activeTabId: existing.id };