Merge pull request #100 from DenchHQ/bp/4-multi-session-chat-ui

feat(chat): multi-session chat tabs with stop controls UI
This commit is contained in:
Kumar Abhirup 2026-03-15 00:33:41 -07:00 committed by GitHub
commit 3efa6f9605
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1096 additions and 409 deletions

View File

@ -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 (
<div className="flex flex-col items-end gap-1.5 py-2">
{!richHtml && <AttachedFilesCard paths={attachmentInfo.paths} />}
<AttachedFilesCard paths={attachmentInfo.paths} />
{(attachmentInfo.message || richHtml) && (
<div
className="max-w-[80%] w-fit rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 break-words chat-message-font"

View File

@ -29,6 +29,11 @@ import {
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { UnicodeSpinner } from "./unicode-spinner";
import type { ChatPanelRuntimeState } from "@/lib/chat-session-registry";
import {
getStreamActivityLabel,
hasAssistantText,
} from "./chat-stream-status";
// ── Prompt suggestions for new chat hero ──
@ -590,6 +595,7 @@ type ParsedPart =
state: string;
input?: Record<string, unknown>;
output?: Record<string, unknown>;
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<ChatPanelHandle, ChatPanelProps>(
@ -836,6 +851,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
subagentLabel,
onBack,
hideHeaderActions,
onRuntimeStateChange,
},
ref,
) {
@ -962,6 +978,25 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
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<ReturnType<typeof setTimeout> | null>(null);
@ -1955,29 +1990,18 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
[],
);
// ── 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<ChatPanelHandle, ChatPanelProps>(
return (
<div
className="h-full flex flex-col"
className="h-full min-h-0 flex flex-col overflow-hidden"
style={{ background: "var(--color-main-bg)" }}
>
{/* Header — sticky glass bar */}
<header
className={`${compact ? "px-3 py-2" : "px-3 py-2 md:px-6 md:py-3"} flex items-center ${isSubagentMode ? "gap-3" : "justify-between"} z-20`}
className={`${compact ? "px-3 py-2" : "px-3 py-2 md:px-6 md:py-3"} flex shrink-0 items-center ${isSubagentMode ? "gap-3" : "justify-between"} z-20`}
>
{isSubagentMode ? (
<>
@ -2280,7 +2304,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
{/* File-scoped session tabs (compact mode, not in subagent mode) */}
{!isSubagentMode && compact && fileContext && fileSessions.length > 0 && (
<div
className="px-2 py-1.5 border-b flex gap-1 overflow-x-auto z-20"
className="px-2 py-1.5 border-b flex shrink-0 gap-1 overflow-x-auto z-20"
style={{
borderColor: "var(--color-border)",
background: "var(--color-bg-glass)",
@ -2319,7 +2343,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto min-h-0"
className="min-h-0 min-w-0 flex-1 overflow-y-auto"
style={{ scrollbarGutter: "stable" }}
>
{/* Messages */}
@ -2444,13 +2468,25 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
userHtmlMap={userHtmlMapRef.current}
/>
))}
{showInlineSpinner && (
{showStreamActivity && (
<div className="py-3 min-w-0">
<UnicodeSpinner
name="pulse"
className="text-base"
style={{ color: "var(--color-text-muted)" }}
/>
<div
className="inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1.5"
style={{
background: "var(--color-surface-hover)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<UnicodeSpinner
name="braille"
className={`text-sm ${lastAssistantHasText ? "" : "opacity-90"}`}
style={{ color: "inherit" }}
/>
<span className="text-xs truncate">
{streamActivityLabel}
</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
@ -2501,7 +2537,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
{/* Input bar at bottom (hidden when hero state is active) */}
{!showHeroState && (
<div
className={`${compact ? "px-3 py-2" : "px-3 pb-3 pt-0 md:px-6 md:pb-5"} z-20`}
className={`${compact ? "px-3 py-2" : "px-3 pb-3 pt-0 md:px-6 md:pb-5"} shrink-0 z-20`}
style={{ background: "var(--color-bg-glass)" }}
>
<div className={compact ? "" : "max-w-[720px] mx-auto"}>

View File

@ -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 (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
);
}
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 (
<div
@ -366,8 +386,23 @@ export function ChatSessionsSidebar({
</div>
</button>
)}
{onDeleteSession && (
<div className={`shrink-0 flex items-center pr-1 transition-opacity ${showMore ? "opacity-100" : "opacity-0"}`}>
<div className={`shrink-0 flex items-center pr-1 gap-0.5 transition-opacity ${showMore ? "opacity-100" : "opacity-0"}`}>
{isStreamingSession && onStopSession && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onStopSession(session.id);
}}
className="flex items-center justify-center w-6 h-6 rounded-md transition-colors hover:bg-black/5"
style={{ color: "var(--color-text-muted)" }}
title="Stop chat"
aria-label="Stop chat"
>
<StopIcon />
</button>
)}
{onDeleteSession && (
<DropdownMenu>
<DropdownMenuTrigger
onClick={(e) => e.stopPropagation()}
@ -394,8 +429,8 @@ export function ChatSessionsSidebar({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
)}
</div>
</div>
{/* 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 (
<button
<div
key={sa.childSessionKey}
type="button"
onClick={() => handleSelectSubagentItem(sa.childSessionKey)}
className="w-full text-left pl-3 pr-2 py-1.5 rounded-r-lg transition-colors cursor-pointer"
style={{
background: isSubActive
? "var(--color-chat-sidebar-active-bg)"
: "transparent",
}}
className="flex items-center"
>
<div className="flex items-center gap-1.5">
{isSubRunning && (
<UnicodeSpinner
name="braille"
className="text-[9px] flex-shrink-0"
style={{ color: "var(--color-chat-sidebar-muted)" }}
/>
)}
<SubagentIcon />
<span
className="text-[11px] truncate"
style={{
color: isSubActive
? "var(--color-chat-sidebar-active-text)"
: "var(--color-text-muted)",
<button
type="button"
onClick={() => handleSelectSubagentItem(sa.childSessionKey)}
className="flex-1 text-left pl-3 pr-2 py-1.5 rounded-r-lg transition-colors cursor-pointer"
style={{
background: isSubActive
? "var(--color-chat-sidebar-active-bg)"
: "transparent",
}}
>
<div className="flex items-center gap-1.5">
{isSubRunning && (
<UnicodeSpinner
name="braille"
className="text-[9px] flex-shrink-0"
style={{ color: "var(--color-chat-sidebar-muted)" }}
/>
)}
<SubagentIcon />
<span
className="text-[11px] truncate"
style={{
color: isSubActive
? "var(--color-chat-sidebar-active-text)"
: "var(--color-text-muted)",
}}
>
{truncated}
</span>
</div>
</button>
{isSubRunning && onStopSubagent && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onStopSubagent(sa.childSessionKey);
}}
className="shrink-0 flex items-center justify-center w-6 h-6 rounded-md mr-1 transition-colors hover:bg-black/5"
style={{ color: "var(--color-text-muted)" }}
title="Stop subagent"
aria-label="Stop subagent"
>
{truncated}
</span>
</div>
</button>
<StopIcon />
</button>
)}
</div>
);
})}
</div>

View File

@ -21,6 +21,8 @@ type TabBarProps = {
onCloseAll: () => void;
onReorder: (fromIndex: number, toIndex: number) => void;
onTogglePin: (tabId: string) => void;
liveChatTabIds?: Set<string>;
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 && (
<>
<div className="h-px my-0.5 mx-1 bg-neutral-400/15" />
<ContextMenuItem
label="Stop Session"
onClick={() => { onStopTab(contextMenu.tabId); setContextMenu(null); }}
/>
</>
)}
<div className="h-px my-0.5 mx-1 bg-neutral-400/15" />
<ContextMenuItem
label="Close"

View File

@ -1974,6 +1974,7 @@ body {
/* Favicon icon classes (SVG data-uri with currentColor replaced by hex) */
.dench-favicon-home,
.dench-favicon-chat,
.dench-favicon-chat-live,
.dench-favicon-file,
.dench-favicon-app,
.dench-favicon-cron,
@ -1989,6 +1990,10 @@ body {
.dench-favicon-chat {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/%3E%3C/svg%3E") !important;
}
.dench-favicon-chat-live {
opacity: 0.9;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/%3E%3Ccircle cx='18' cy='18' r='3' fill='%2310b981' stroke='none'/%3E%3C/svg%3E") !important;
}
.dench-favicon-file {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3C/svg%3E") !important;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
export type ChatPanelRuntimeState = {
sessionId: string | null;
sessionKey: string | null;
isStreaming: boolean;
status: string;
isReconnecting: boolean;
loadingSession: boolean;
};
export type ChatTabRuntimeSnapshot = ChatPanelRuntimeState & {
tabId: string;
};
export type ChatRunsSnapshot = {
parentStatuses: Map<string, "running" | "waiting-for-subagents" | "completed" | "error">;
subagentStatuses: Map<string, "running" | "completed" | "error">;
};
export function mergeChatRuntimeSnapshot(
state: Record<string, ChatTabRuntimeSnapshot>,
snapshot: ChatTabRuntimeSnapshot,
): Record<string, ChatTabRuntimeSnapshot> {
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<string, ChatTabRuntimeSnapshot>,
tabId: string,
): Record<string, ChatTabRuntimeSnapshot> {
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])),
};
}

View File

@ -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",
});
});
});

178
apps/web/lib/chat-tabs.ts Normal file
View File

@ -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,
};
}

View File

@ -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 };