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