From 07210d0c912398f405a318cda7ea627126616c82 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Feb 2026 21:06:03 -0800 Subject: [PATCH] Migrate thread terminals to grouped layout model - Replace `terminalLayout`/`splitTerminalIds` with `terminalGroups` and `activeTerminalGroupId` - Update terminal drawer UI to render grouped splits with a terminal sidebar - Bump persisted web state to v7 with legacy v6 migration coverage in tests --- apps/web/src/App.tsx | 9 +- apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/Sidebar.tsx | 9 +- .../src/components/ThreadTerminalDrawer.tsx | 422 ++++++++++++------ apps/web/src/persistenceSchema.test.ts | 96 +++- apps/web/src/persistenceSchema.ts | 125 ++++-- apps/web/src/store.test.ts | 106 ++++- apps/web/src/store.ts | 292 ++++++++---- apps/web/src/types.ts | 10 +- 9 files changed, 790 insertions(+), 283 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index bcb5ac6912daede55e0dc8f56de21eac8840332e..a982c1fc6e85868b132d93e392cc640433b485d9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -83,8 +83,13 @@ function AutoProjectBootstrap() { terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalLayout: "single", - splitTerminalIds: [], + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: null, messages: [], events: [], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4fa52c843d321c34b86fc2951d1e7ac4f3bc22b4..7b1a85eaad97710ab48921ed1cd282705619909b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1498,8 +1498,8 @@ export default function ChatView() { height={activeThread.terminalHeight} terminalIds={activeThread.terminalIds} activeTerminalId={activeThread.activeTerminalId} - terminalLayout={activeThread.terminalLayout} - splitTerminalIds={activeThread.splitTerminalIds} + terminalGroups={activeThread.terminalGroups} + activeTerminalGroupId={activeThread.activeTerminalGroupId} focusRequestId={terminalFocusRequestId} onSplitTerminal={splitTerminal} onNewTerminal={createNewTerminal} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f5634044fd6b6a1476e39d6069ef0cd3f6bdabfe..32a832a491a6160dcb9b219cc52c49af6be1de61 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -119,8 +119,13 @@ export default function Sidebar() { terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalLayout: "single", - splitTerminalIds: [], + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: null, messages: [], events: [], diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 783e5886481d879e647f1645b10ca9d80e381751..68275bfc0d094d70299584a26a4fb94e7e04f9f6 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,4 +1,5 @@ import { FitAddon } from "@xterm/addon-fit"; +import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react"; import { type NativeApi } from "@t3tools/contracts"; import { Terminal, type ITheme } from "@xterm/xterm"; import { @@ -19,7 +20,7 @@ import { isTerminalClearShortcut } from "../terminal-shortcuts"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, - type ThreadTerminalLayout, + type ThreadTerminalGroup, } from "../types"; const MIN_DRAWER_HEIGHT = 180; @@ -415,8 +416,8 @@ interface ThreadTerminalDrawerProps { height: number; terminalIds: string[]; activeTerminalId: string; - terminalLayout: ThreadTerminalLayout; - splitTerminalIds: string[]; + terminalGroups: ThreadTerminalGroup[]; + activeTerminalGroupId: string; focusRequestId: number; onSplitTerminal: () => void; onNewTerminal: () => void; @@ -432,8 +433,8 @@ export default function ThreadTerminalDrawer({ height, terminalIds, activeTerminalId, - terminalLayout, - splitTerminalIds, + terminalGroups, + activeTerminalGroupId, focusRequestId, onSplitTerminal, onNewTerminal, @@ -462,28 +463,88 @@ export default function ThreadTerminalDrawer({ ? activeTerminalId : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); - const resolvedSplitTerminalIds = useMemo(() => { - if (terminalLayout !== "split") return []; - const validSplitIds = [...new Set(splitTerminalIds)].filter((id) => - normalizedTerminalIds.includes(id), - ); - if (validSplitIds.length >= 2) { - return validSplitIds.slice(0, 2); + const resolvedTerminalGroups = useMemo(() => { + const validTerminalIdSet = new Set(normalizedTerminalIds); + const assignedTerminalIds = new Set(); + const usedGroupIds = new Set(); + const nextGroups: ThreadTerminalGroup[] = []; + + const assignUniqueGroupId = (groupId: string): string => { + if (!usedGroupIds.has(groupId)) { + usedGroupIds.add(groupId); + return groupId; + } + let suffix = 2; + while (usedGroupIds.has(`${groupId}-${suffix}`)) { + suffix += 1; + } + const uniqueGroupId = `${groupId}-${suffix}`; + usedGroupIds.add(uniqueGroupId); + return uniqueGroupId; + }; + + for (const terminalGroup of terminalGroups) { + const nextTerminalIds = [ + ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), + ].filter((terminalId) => { + if (!validTerminalIdSet.has(terminalId)) return false; + if (assignedTerminalIds.has(terminalId)) return false; + return true; + }); + if (nextTerminalIds.length === 0) continue; + + for (const terminalId of nextTerminalIds) { + assignedTerminalIds.add(terminalId); + } + + const baseGroupId = + terminalGroup.id.trim().length > 0 + ? terminalGroup.id.trim() + : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; + nextGroups.push({ + id: assignUniqueGroupId(baseGroupId), + terminalIds: nextTerminalIds, + }); } - const fallbackPartner = normalizedTerminalIds.find((id) => id !== resolvedActiveTerminalId); - if (!fallbackPartner) { - return []; + for (const terminalId of normalizedTerminalIds) { + if (assignedTerminalIds.has(terminalId)) continue; + nextGroups.push({ + id: assignUniqueGroupId(`group-${terminalId}`), + terminalIds: [terminalId], + }); } - return [resolvedActiveTerminalId, fallbackPartner]; - }, [normalizedTerminalIds, resolvedActiveTerminalId, splitTerminalIds, terminalLayout]); + + if (nextGroups.length > 0) { + return nextGroups; + } + + return [ + { + id: `group-${resolvedActiveTerminalId}`, + terminalIds: [resolvedActiveTerminalId], + }, + ]; + }, [normalizedTerminalIds, resolvedActiveTerminalId, terminalGroups]); + + const resolvedActiveGroupIndex = useMemo(() => { + const indexById = resolvedTerminalGroups.findIndex( + (terminalGroup) => terminalGroup.id === activeTerminalGroupId, + ); + if (indexById >= 0) return indexById; + const indexByTerminal = resolvedTerminalGroups.findIndex((terminalGroup) => + terminalGroup.terminalIds.includes(resolvedActiveTerminalId), + ); + return indexByTerminal >= 0 ? indexByTerminal : 0; + }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]); const visibleTerminalIds = - terminalLayout === "split" && resolvedSplitTerminalIds.length === 2 - ? resolvedSplitTerminalIds - : [resolvedActiveTerminalId]; - - const showTabBar = terminalLayout === "tabs" && normalizedTerminalIds.length > 1; + resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? [resolvedActiveTerminalId]; + const hasTerminalSidebar = normalizedTerminalIds.length > 1; + const isSplitView = visibleTerminalIds.length > 1; + const showGroupHeaders = + resolvedTerminalGroups.length > 1 || + resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); const terminalLabelById = useMemo( () => new Map( @@ -585,147 +646,218 @@ export default function ThreadTerminalDrawer({ return ( ); diff --git a/apps/web/src/persistenceSchema.test.ts b/apps/web/src/persistenceSchema.test.ts index 13d934c7a8d8c44d0de53d96b6c9349e29b62a8b..b05fbf352ae88aa351131352d8ae423a3325dafe 100644 --- a/apps/web/src/persistenceSchema.test.ts +++ b/apps/web/src/persistenceSchema.test.ts @@ -52,8 +52,12 @@ describe("hydratePersistedState", () => { expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(hydrated?.threads[0]?.terminalLayout).toBe("single"); - expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]); + expect(hydrated?.threads[0]?.terminalGroups).toEqual([ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + ]); + expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe( + `group-${DEFAULT_THREAD_TERMINAL_ID}`, + ); expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); expect(hydrated?.runtimeMode).toBe("full-access"); }); @@ -98,8 +102,12 @@ describe("hydratePersistedState", () => { expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(hydrated?.threads[0]?.terminalLayout).toBe("single"); - expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]); + expect(hydrated?.threads[0]?.terminalGroups).toEqual([ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + ]); + expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe( + `group-${DEFAULT_THREAD_TERMINAL_ID}`, + ); expect(hydrated?.activeThreadId).toBe("t-1"); expect(hydrated?.runtimeMode).toBe("full-access"); }); @@ -125,7 +133,7 @@ describe("hydratePersistedState", () => { expect(hydrated?.runtimeMode).toBe("approval-required"); }); - it("hydrates terminal fields from v6 payload", () => { + it("hydrates terminal fields from legacy v6 payload", () => { const payload = JSON.stringify({ version: 6, runtimeMode: "full-access", @@ -164,11 +172,60 @@ describe("hydratePersistedState", () => { expect(hydrated?.threads[0]?.terminalHeight).toBe(360); expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); expect(hydrated?.threads[0]?.activeTerminalId).toBe("term-2"); - expect(hydrated?.threads[0]?.terminalLayout).toBe("tabs"); - expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]); + expect(hydrated?.threads[0]?.terminalGroups).toEqual([ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + { id: "group-term-2", terminalIds: ["term-2"] }, + ]); + expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe("group-term-2"); expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z"); }); + it("hydrates legacy split layout into a grouped split", () => { + const payload = JSON.stringify({ + version: 6, + runtimeMode: "full-access", + projects: [ + { + id: "p-1", + name: "Project", + cwd: "/tmp/project", + model: "gpt-5.3-codex", + expanded: true, + }, + ], + threads: [ + { + id: "t-1", + codexThreadId: null, + projectId: "p-1", + title: "Thread", + model: "gpt-5.3-codex", + terminalOpen: true, + terminalHeight: 360, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], + activeTerminalId: "term-2", + terminalLayout: "split", + splitTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + messages: [], + createdAt: "2026-02-08T10:00:00.000Z", + }, + ], + activeThreadId: "t-1", + }); + + const hydrated = hydratePersistedState(payload, false); + expect(hydrated?.threads[0]?.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + }, + { id: "group-term-3", terminalIds: ["term-3"] }, + ]); + expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe( + `group-${DEFAULT_THREAD_TERMINAL_ID}`, + ); + }); + it("defaults terminalHeight when hydrating v5 payloads", () => { const payload = JSON.stringify({ version: 5, @@ -201,12 +258,17 @@ describe("hydratePersistedState", () => { expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(hydrated?.threads[0]?.terminalLayout).toBe("single"); + expect(hydrated?.threads[0]?.terminalGroups).toEqual([ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + ]); + expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe( + `group-${DEFAULT_THREAD_TERMINAL_ID}`, + ); }); }); describe("toPersistedState", () => { - it("writes v6 payload and strips non-persisted thread fields", () => { + it("writes v7 payload and strips non-persisted thread fields", () => { const thread: Thread = { id: "t-1", codexThreadId: "thr_1", @@ -217,8 +279,11 @@ describe("toPersistedState", () => { terminalHeight: 320, terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], activeTerminalId: "term-2", - terminalLayout: "tabs", - splitTerminalIds: [], + terminalGroups: [ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + { id: "group-term-2", terminalIds: ["term-2"] }, + ], + activeTerminalGroupId: "group-term-2", session: null, messages: [ { @@ -262,7 +327,7 @@ describe("toPersistedState", () => { runtimeMode: "full-access", }); - expect(persisted.version).toBe(6); + expect(persisted.version).toBe(7); expect(persisted.runtimeMode).toBe("full-access"); expect(persisted.threads[0]).toEqual({ id: "t-1", @@ -274,8 +339,11 @@ describe("toPersistedState", () => { terminalHeight: 320, terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], activeTerminalId: "term-2", - terminalLayout: "tabs", - splitTerminalIds: [], + terminalGroups: [ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + { id: "group-term-2", terminalIds: ["term-2"] }, + ], + activeTerminalGroupId: "group-term-2", messages: [ { id: "m-1", diff --git a/apps/web/src/persistenceSchema.ts b/apps/web/src/persistenceSchema.ts index 1b24086b375e2e31a9b041364b364741711221af..5738107c3ca77a0723f6d9fdf07118d25824427f 100644 --- a/apps/web/src/persistenceSchema.ts +++ b/apps/web/src/persistenceSchema.ts @@ -8,6 +8,7 @@ import { type Project, type RuntimeMode, type Thread, + type ThreadTerminalGroup, } from "./types"; const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; @@ -39,6 +40,11 @@ const persistedMessageSchema = z.object({ streaming: z.boolean(), }); +const persistedTerminalGroupSchema = z.object({ + id: z.string().trim().min(1), + terminalIds: z.array(z.string().trim().min(1)), +}); + const persistedThreadSchema = z.object({ id: z.string().min(1), codexThreadId: z.string().min(1).nullable().default(null), @@ -51,8 +57,11 @@ const persistedThreadSchema = z.object({ ), terminalIds: z.array(z.string().trim().min(1)).default([DEFAULT_THREAD_TERMINAL_ID]), activeTerminalId: z.string().trim().min(1).default(DEFAULT_THREAD_TERMINAL_ID), - terminalLayout: z.enum(["single", "split", "tabs"]).default("single"), - splitTerminalIds: z.array(z.string().trim().min(1)).default([]), + terminalGroups: z.array(persistedTerminalGroupSchema).default([]), + activeTerminalGroupId: z.string().trim().min(1).optional(), + // Legacy v6 and older fields retained for migration. + terminalLayout: z.enum(["single", "split", "tabs"]).optional(), + splitTerminalIds: z.array(z.string().trim().min(1)).optional(), messages: z.array(persistedMessageSchema), createdAt: z.string().min(1), lastVisitedAt: z.string().min(1).optional(), @@ -91,12 +100,18 @@ export const persistedStateV6Schema = persistedStateBodySchema.extend({ version: z.literal(6).optional(), }); +export const persistedStateV7Schema = persistedStateBodySchema.extend({ + runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), + version: z.literal(7).optional(), +}); + export const persistedStateV5Schema = persistedStateBodySchema.extend({ runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), version: z.literal(5).optional(), }); const persistedStateSchema = z.union([ + persistedStateV7Schema, persistedStateV6Schema, persistedStateV5Schema, persistedStateV4Schema, @@ -134,33 +149,91 @@ function hydrateThread( thread: z.infer, isLegacyPayload: boolean, ): Thread { - const terminalIds = [...new Set(thread.terminalIds)] - .map((id) => id.trim()) - .filter((id) => id.length > 0); + const terminalIds = [...new Set(thread.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; const safeTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; const activeTerminalId = safeTerminalIds.includes(thread.activeTerminalId) ? thread.activeTerminalId : (safeTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); - let terminalLayout = thread.terminalLayout; - let splitTerminalIds = [...new Set(thread.splitTerminalIds)].filter((id) => - safeTerminalIds.includes(id), - ); - if (terminalLayout === "split") { - splitTerminalIds = splitTerminalIds.slice(0, 2); - if (splitTerminalIds.length < 2) { - terminalLayout = safeTerminalIds.length > 1 ? "tabs" : "single"; - splitTerminalIds = []; + const safeTerminalIdSet = new Set(safeTerminalIds); + const assignedTerminalIds = new Set(); + const usedGroupIds = new Set(); + const normalizedGroups: ThreadTerminalGroup[] = []; + const assignUniqueGroupId = (groupId: string): string => { + if (!usedGroupIds.has(groupId)) { + usedGroupIds.add(groupId); + return groupId; + } + let suffix = 2; + while (usedGroupIds.has(`${groupId}-${suffix}`)) { + suffix += 1; } - } else { - splitTerminalIds = []; + const uniqueGroupId = `${groupId}-${suffix}`; + usedGroupIds.add(uniqueGroupId); + return uniqueGroupId; + }; + + for (const terminalGroup of thread.terminalGroups) { + const nextTerminalIds = [ + ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), + ].filter((terminalId) => { + if (!safeTerminalIdSet.has(terminalId)) return false; + if (assignedTerminalIds.has(terminalId)) return false; + return true; + }); + if (nextTerminalIds.length === 0) continue; + for (const terminalId of nextTerminalIds) { + assignedTerminalIds.add(terminalId); + } + const baseGroupId = + terminalGroup.id.trim().length > 0 + ? terminalGroup.id.trim() + : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; + normalizedGroups.push({ + id: assignUniqueGroupId(baseGroupId), + terminalIds: nextTerminalIds, + }); } - if (terminalLayout === "single" && safeTerminalIds.length > 1) { - terminalLayout = "tabs"; - } else if (terminalLayout === "tabs" && safeTerminalIds.length < 2) { - terminalLayout = "single"; + + if (normalizedGroups.length === 0 && thread.terminalLayout === "split") { + const splitTerminalIds = [ + ...new Set((thread.splitTerminalIds ?? []).map((id) => id.trim()).filter((id) => id.length > 0)), + ].filter((terminalId) => safeTerminalIdSet.has(terminalId)); + if (splitTerminalIds.length >= 2) { + const splitGroupTerminalIds = splitTerminalIds.slice(0, 2); + for (const terminalId of splitGroupTerminalIds) { + assignedTerminalIds.add(terminalId); + } + normalizedGroups.push({ + id: assignUniqueGroupId(`group-${splitGroupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`), + terminalIds: splitGroupTerminalIds, + }); + } } + for (const terminalId of safeTerminalIds) { + if (assignedTerminalIds.has(terminalId)) continue; + normalizedGroups.push({ + id: assignUniqueGroupId(`group-${terminalId}`), + terminalIds: [terminalId], + }); + } + + const activeGroupIndexFromId = normalizedGroups.findIndex( + (terminalGroup) => terminalGroup.id === thread.activeTerminalGroupId, + ); + const activeGroupIndexFromTerminal = normalizedGroups.findIndex((terminalGroup) => + terminalGroup.terminalIds.includes(activeTerminalId), + ); + const activeGroupIndex = + activeGroupIndexFromId >= 0 + ? activeGroupIndexFromId + : (activeGroupIndexFromTerminal >= 0 ? activeGroupIndexFromTerminal : 0); + const activeTerminalGroupId = + normalizedGroups[activeGroupIndex]?.id ?? + normalizedGroups[0]?.id ?? + `group-${DEFAULT_THREAD_TERMINAL_ID}`; + return { id: thread.id, codexThreadId: thread.codexThreadId, @@ -171,8 +244,8 @@ function hydrateThread( terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: safeTerminalIds, activeTerminalId, - terminalLayout, - splitTerminalIds, + terminalGroups: normalizedGroups, + activeTerminalGroupId, session: null, messages: thread.messages.map((message) => { const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment })); @@ -235,9 +308,9 @@ export function hydratePersistedState( export function toPersistedState( state: PersistedStoreSnapshot, -): z.infer { +): z.infer { return { - version: 6, + version: 7, projects: state.projects, threads: state.threads.map((thread) => ({ id: thread.id, @@ -249,8 +322,8 @@ export function toPersistedState( terminalHeight: thread.terminalHeight, terminalIds: thread.terminalIds, activeTerminalId: thread.activeTerminalId, - terminalLayout: thread.terminalLayout, - splitTerminalIds: thread.splitTerminalIds, + terminalGroups: thread.terminalGroups, + activeTerminalGroupId: thread.activeTerminalGroupId, messages: thread.messages.map((message) => ({ id: message.id, role: message.role, diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 8e5e6d011c3a3b83e5ad9751b5920ba0277924a5..4a5dd0e099e008b6b36802f9acf7a971cb41c177 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -39,8 +39,13 @@ function makeThread(overrides: Partial = {}): Thread { terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalLayout: "single", - splitTerminalIds: [], + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: makeSession(), messages: [], events: [], @@ -123,10 +128,15 @@ describe("store reducer thread continuity", () => { terminalId: "term-2", }); - expect(next.threads[0]?.terminalLayout).toBe("split"); expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); - expect(next.threads[0]?.splitTerminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); expect(next.threads[0]?.activeTerminalId).toBe("term-2"); + expect(next.threads[0]?.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + }, + ]); + expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); }); it("creates a new full-width terminal and switches to tab mode", () => { @@ -137,18 +147,31 @@ describe("store reducer thread continuity", () => { terminalId: "term-2", }); - expect(next.threads[0]?.terminalLayout).toBe("tabs"); expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); expect(next.threads[0]?.activeTerminalId).toBe("term-2"); - expect(next.threads[0]?.splitTerminalIds).toEqual([]); + expect(next.threads[0]?.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + { id: "group-term-2", terminalIds: ["term-2"] }, + ]); + expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-2"); }); - it("switches the active tab terminal", () => { + it("switches the active terminal and restores its owning group", () => { const state = makeState( makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalLayout: "tabs", + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + }, + { id: "group-term-3", terminalIds: ["term-3"] }, + ], + activeTerminalGroupId: "group-term-3", }), ); const next = reducer(state, { @@ -158,14 +181,57 @@ describe("store reducer thread continuity", () => { }); expect(next.threads[0]?.activeTerminalId).toBe("term-2"); + expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); }); - it("closes a terminal tab and falls back to single layout when one remains", () => { + it("supports splitting beyond two terminals in the same group", () => { const state = makeState( makeThread({ terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], activeTerminalId: "term-2", - terminalLayout: "tabs", + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + }, + ], + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + }), + ); + const next = reducer(state, { + type: "SPLIT_THREAD_TERMINAL", + threadId: "thread-local-1", + terminalId: "term-3", + }); + + expect(next.threads[0]?.terminalIds).toEqual([ + DEFAULT_THREAD_TERMINAL_ID, + "term-2", + "term-3", + ]); + expect(next.threads[0]?.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], + }, + ]); + expect(next.threads[0]?.activeTerminalId).toBe("term-3"); + expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); + }); + + it("closes a terminal and keeps grouped layout coherent", () => { + const state = makeState( + makeThread({ + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], + activeTerminalId: "term-2", + terminalGroups: [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + }, + { id: "group-term-3", terminalIds: ["term-3"] }, + ], + activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, }), ); const next = reducer(state, { @@ -174,9 +240,15 @@ describe("store reducer thread continuity", () => { terminalId: "term-2", }); - expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); + expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-3"]); expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(next.threads[0]?.terminalLayout).toBe("single"); + expect(next.threads[0]?.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + { id: "group-term-3", terminalIds: ["term-3"] }, + ]); }); it("closes the final terminal and hides the drawer", () => { @@ -193,7 +265,13 @@ describe("store reducer thread continuity", () => { expect(next.threads[0]?.terminalOpen).toBe(false); expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); - expect(next.threads[0]?.terminalLayout).toBe("single"); + expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); + expect(next.threads[0]?.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ]); }); it("backfills codexThreadId from routed provider events", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index bd2fe31a9b94c9547e23383da9154463d0aae698..3527d46b0fc1a848793e9c14f383554ff40c6ac8 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -19,6 +19,7 @@ import { type Project, type RuntimeMode, type Thread, + type ThreadTerminalGroup, } from "./types"; // ── Actions ────────────────────────────────────────────────────────── @@ -72,8 +73,9 @@ export interface AppState { diffOpen: boolean; } -const PERSISTED_STATE_KEY = "t3code:renderer-state:v6"; +const PERSISTED_STATE_KEY = "t3code:renderer-state:v7"; const LEGACY_PERSISTED_STATE_KEYS = [ + "t3code:renderer-state:v6", "t3code:renderer-state:v5", "t3code:renderer-state:v4", "t3code:renderer-state:v3", @@ -139,9 +141,7 @@ function updateThread( } function normalizeTerminalIds(terminalIds: string[]): string[] { - const ids = terminalIds - .map((id) => id.trim()) - .filter((id) => id.length > 0); + const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); const unique = [...new Set(ids)]; if (unique.length > 0) { return unique; @@ -149,44 +149,112 @@ function normalizeTerminalIds(terminalIds: string[]): string[] { return [DEFAULT_THREAD_TERMINAL_ID]; } +function normalizeTerminalGroupIds(terminalIds: string[]): string[] { + return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; +} + +function fallbackGroupId(terminalId: string): string { + return `group-${terminalId}`; +} + +function assignUniqueGroupId(groupId: string, usedGroupIds: Set): string { + if (!usedGroupIds.has(groupId)) { + usedGroupIds.add(groupId); + return groupId; + } + let suffix = 2; + while (usedGroupIds.has(`${groupId}-${suffix}`)) { + suffix += 1; + } + const uniqueGroupId = `${groupId}-${suffix}`; + usedGroupIds.add(uniqueGroupId); + return uniqueGroupId; +} + +function normalizeTerminalGroups( + thread: Thread, + terminalIds: string[], +): ThreadTerminalGroup[] { + const validTerminalIdSet = new Set(terminalIds); + const assignedTerminalIds = new Set(); + const usedGroupIds = new Set(); + const groups: ThreadTerminalGroup[] = []; + + for (const group of thread.terminalGroups) { + const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { + if (!validTerminalIdSet.has(terminalId)) return false; + if (assignedTerminalIds.has(terminalId)) return false; + return true; + }); + if (groupTerminalIds.length === 0) continue; + for (const terminalId of groupTerminalIds) { + assignedTerminalIds.add(terminalId); + } + const baseGroupId = + group.id.trim().length > 0 + ? group.id.trim() + : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + groups.push({ + id: assignUniqueGroupId(baseGroupId, usedGroupIds), + terminalIds: groupTerminalIds, + }); + } + + for (const terminalId of terminalIds) { + if (assignedTerminalIds.has(terminalId)) continue; + groups.push({ + id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), + terminalIds: [terminalId], + }); + } + + if (groups.length > 0) { + return groups; + } + + return [ + { + id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ]; +} + +function findGroupIndexByTerminalId( + terminalGroups: ThreadTerminalGroup[], + terminalId: string, +): number { + return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); +} + function normalizeThreadTerminals(thread: Thread): Thread { const terminalIds = normalizeTerminalIds(thread.terminalIds); const activeTerminalId = terminalIds.includes(thread.activeTerminalId) ? thread.activeTerminalId : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); - - if (thread.terminalLayout === "split") { - const validSplitIds = [...new Set(thread.splitTerminalIds)].filter((id) => - terminalIds.includes(id), - ); - if (validSplitIds.length >= 2) { - const splitTerminalIds = validSplitIds.slice(0, 2); - const splitActiveTerminalId = splitTerminalIds.includes(activeTerminalId) - ? activeTerminalId - : (splitTerminalIds[1] ?? splitTerminalIds[0] ?? activeTerminalId); - return { - ...thread, - terminalIds, - activeTerminalId: splitActiveTerminalId, - terminalLayout: "split", - splitTerminalIds, - }; - } - } - - const terminalLayout = - terminalIds.length > 1 && thread.terminalLayout === "single" - ? "tabs" - : (thread.terminalLayout === "tabs" && terminalIds.length < 2 - ? "single" - : thread.terminalLayout); + const terminalGroups = normalizeTerminalGroups(thread, terminalIds); + const activeGroupIndexFromId = terminalGroups.findIndex( + (group) => group.id === thread.activeTerminalGroupId, + ); + const activeGroupIndexFromTerminal = findGroupIndexByTerminalId( + terminalGroups, + activeTerminalId, + ); + const activeGroupIndex = + activeGroupIndexFromId >= 0 + ? activeGroupIndexFromId + : (activeGroupIndexFromTerminal >= 0 ? activeGroupIndexFromTerminal : 0); + const activeTerminalGroupId = + terminalGroups[activeGroupIndex]?.id ?? + terminalGroups[0]?.id ?? + fallbackGroupId(activeTerminalId); return { ...thread, terminalIds, activeTerminalId, - terminalLayout, - splitTerminalIds: [], + terminalGroups, + activeTerminalGroupId, }; } @@ -202,33 +270,47 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { terminalOpen: false, terminalIds: [DEFAULT_THREAD_TERMINAL_ID], activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalLayout: "single", - splitTerminalIds: [], + terminalGroups: [ + { + id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), }); } + const closedTerminalIndex = thread.terminalIds.indexOf(terminalId); + const closedTerminalGroup = thread.terminalGroups.find((group) => + group.terminalIds.includes(terminalId), + ); + const closedTerminalGroupIndex = closedTerminalGroup + ? closedTerminalGroup.terminalIds.indexOf(terminalId) + : -1; + const remainingTerminalsInClosedGroup = ( + closedTerminalGroup?.terminalIds ?? [] + ).filter((id) => id !== terminalId); const nextActiveTerminalId = thread.activeTerminalId === terminalId - ? (remainingTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID) + ? (remainingTerminalsInClosedGroup[ + Math.min(closedTerminalGroupIndex, remainingTerminalsInClosedGroup.length - 1) + ] ?? + remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? + remainingTerminalIds[0] ?? + DEFAULT_THREAD_TERMINAL_ID) : thread.activeTerminalId; - let nextLayout = thread.terminalLayout; - let nextSplitTerminalIds = thread.splitTerminalIds.filter((id) => id !== terminalId); - - if (nextLayout === "split" && nextSplitTerminalIds.length < 2) { - nextLayout = remainingTerminalIds.length > 1 ? "tabs" : "single"; - nextSplitTerminalIds = []; - } - - if (nextLayout === "tabs" && remainingTerminalIds.length < 2) { - nextLayout = "single"; - } + const nextTerminalGroups = thread.terminalGroups + .map((group) => ({ + ...group, + terminalIds: group.terminalIds.filter((id) => id !== terminalId), + })) + .filter((group) => group.terminalIds.length > 0); return normalizeThreadTerminals({ ...thread, terminalIds: remainingTerminalIds, activeTerminalId: nextActiveTerminalId, - terminalLayout: nextLayout, - splitTerminalIds: nextSplitTerminalIds, + terminalGroups: nextTerminalGroups, }); } @@ -440,28 +522,67 @@ export function reducer(state: AppState, action: Action): AppState { return { ...state, threads: updateThread(state.threads, action.threadId, (thread) => { - const terminalIds = thread.terminalIds.includes(action.terminalId) - ? thread.terminalIds - : [...thread.terminalIds, action.terminalId]; - const firstTerminalId = - terminalIds.includes(thread.activeTerminalId) - ? thread.activeTerminalId - : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); - const secondTerminalId = - action.terminalId === firstTerminalId - ? (terminalIds.find((id) => id !== firstTerminalId) ?? firstTerminalId) - : action.terminalId; - const splitTerminalIds = - firstTerminalId === secondTerminalId - ? [] - : [firstTerminalId, secondTerminalId]; + const normalizedThread = normalizeThreadTerminals(thread); + const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) + ? normalizedThread.terminalIds + : [...normalizedThread.terminalIds, action.terminalId]; + const terminalGroups = normalizedThread.terminalGroups.map((group) => ({ + ...group, + terminalIds: [...group.terminalIds], + })); + let activeGroupIndex = terminalGroups.findIndex( + (group) => group.id === normalizedThread.activeTerminalGroupId, + ); + if (activeGroupIndex < 0) { + activeGroupIndex = findGroupIndexByTerminalId( + terminalGroups, + normalizedThread.activeTerminalId, + ); + } + if (activeGroupIndex < 0) { + terminalGroups.push({ + id: fallbackGroupId(normalizedThread.activeTerminalId), + terminalIds: [normalizedThread.activeTerminalId], + }); + activeGroupIndex = terminalGroups.length - 1; + } + const existingGroupIndex = findGroupIndexByTerminalId( + terminalGroups, + action.terminalId, + ); + if (existingGroupIndex >= 0) { + terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[ + existingGroupIndex + ]!.terminalIds.filter((id) => id !== action.terminalId); + if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { + terminalGroups.splice(existingGroupIndex, 1); + if (existingGroupIndex < activeGroupIndex) { + activeGroupIndex -= 1; + } + } + } + + const destinationGroup = terminalGroups[activeGroupIndex]; + if (!destinationGroup) { + return normalizedThread; + } + if (!destinationGroup.terminalIds.includes(action.terminalId)) { + const anchorIndex = destinationGroup.terminalIds.indexOf( + normalizedThread.activeTerminalId, + ); + if (anchorIndex >= 0) { + destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId); + } else { + destinationGroup.terminalIds.push(action.terminalId); + } + } return normalizeThreadTerminals({ - ...thread, + ...normalizedThread, terminalIds, - activeTerminalId: secondTerminalId, - terminalLayout: splitTerminalIds.length === 2 ? "split" : "single", - splitTerminalIds, + activeTerminalId: action.terminalId, + activeTerminalGroupId: destinationGroup.id, + terminalGroups, }); }), }; @@ -470,15 +591,25 @@ export function reducer(state: AppState, action: Action): AppState { return { ...state, threads: updateThread(state.threads, action.threadId, (thread) => { - const terminalIds = thread.terminalIds.includes(action.terminalId) - ? thread.terminalIds - : [...thread.terminalIds, action.terminalId]; + const normalizedThread = normalizeThreadTerminals(thread); + const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) + ? normalizedThread.terminalIds + : [...normalizedThread.terminalIds, action.terminalId]; + const terminalGroups = normalizedThread.terminalGroups + .map((group) => ({ + ...group, + terminalIds: group.terminalIds.filter((id) => id !== action.terminalId), + })) + .filter((group) => group.terminalIds.length > 0); + const nextGroupId = fallbackGroupId(action.terminalId); + terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] }); + return normalizeThreadTerminals({ - ...thread, + ...normalizedThread, terminalIds, activeTerminalId: action.terminalId, - terminalLayout: "tabs", - splitTerminalIds: [], + activeTerminalGroupId: nextGroupId, + terminalGroups, }); }), }; @@ -487,12 +618,23 @@ export function reducer(state: AppState, action: Action): AppState { return { ...state, threads: updateThread(state.threads, action.threadId, (thread) => { - if (!thread.terminalIds.includes(action.terminalId)) { + const normalizedThread = normalizeThreadTerminals(thread); + if (!normalizedThread.terminalIds.includes(action.terminalId)) { return thread; } + const nextActiveGroupIndex = findGroupIndexByTerminalId( + normalizedThread.terminalGroups, + action.terminalId, + ); + const activeTerminalGroupId = + nextActiveGroupIndex >= 0 + ? (normalizedThread.terminalGroups[nextActiveGroupIndex]?.id ?? + normalizedThread.activeTerminalGroupId) + : normalizedThread.activeTerminalGroupId; return normalizeThreadTerminals({ - ...thread, + ...normalizedThread, activeTerminalId: action.terminalId, + activeTerminalGroupId, }); }), }; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 8091557f78ebd7fa2299f0838f05dcb1b0a65027..56343f128e9b1eb2e262eb5ea592973c9a53cc50 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -5,7 +5,11 @@ export type RuntimeMode = "approval-required" | "full-access"; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; export const DEFAULT_THREAD_TERMINAL_ID = "default"; -export type ThreadTerminalLayout = "single" | "split" | "tabs"; + +export interface ThreadTerminalGroup { + id: string; + terminalIds: string[]; +} export interface ChatImageAttachment { type: "image"; @@ -45,8 +49,8 @@ export interface Thread { terminalHeight: number; terminalIds: string[]; activeTerminalId: string; - terminalLayout: ThreadTerminalLayout; - splitTerminalIds: string[]; + terminalGroups: ThreadTerminalGroup[]; + activeTerminalGroupId: string; session: ProviderSession | null; messages: ChatMessage[]; events: ProviderEvent[]; -- 2.54.0