History

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

Julius Marminge committed 9 months ago

Commit a435fa51 parent 817c5e69 files · +783 −276Browse files

Unified · Split

9 files
  1. apps/web/src/App.tsx +7 −2
  2. apps/web/src/components/ChatView.tsx +2 −2
  3. apps/web/src/components/Sidebar.tsx +7 −2
  4. apps/web/src/components/ThreadTerminalDrawer.tsx +270 −138
  5. apps/web/src/persistenceSchema.test.ts +82 −14
  6. apps/web/src/persistenceSchema.ts +99 −26
  7. apps/web/src/store.test.ts +92 −14
  8. apps/web/src/store.ts +217 −75
  9. apps/web/src/types.ts +7 −3
apps/web/src/App.tsx+7−2
@@ -83,8 +83,13 @@ function AutoProjectBootstrap() { · 82 unmodified lines
8383 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
8484 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
8585 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
86− terminalLayout: "single",
87− splitTerminalIds: [],
86+ terminalGroups: [
87+ {
88+ id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
89+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
90+ },
91+ ],
92+ activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
8893 session: null,
8994 messages: [],
9095 events: [],
apps/web/src/components/ChatView.tsx+2−2
@@ -1498,8 +1498,8 @@ export default function ChatView() { · 1497 unmodified lines
14981498 height={activeThread.terminalHeight}
14991499 terminalIds={activeThread.terminalIds}
15001500 activeTerminalId={activeThread.activeTerminalId}
1501− terminalLayout={activeThread.terminalLayout}
1502− splitTerminalIds={activeThread.splitTerminalIds}
1501+ terminalGroups={activeThread.terminalGroups}
1502+ activeTerminalGroupId={activeThread.activeTerminalGroupId}
15031503 focusRequestId={terminalFocusRequestId}
15041504 onSplitTerminal={splitTerminal}
15051505 onNewTerminal={createNewTerminal}
apps/web/src/components/Sidebar.tsx+7−2
@@ -119,8 +119,13 @@ export default function Sidebar() { · 118 unmodified lines
119119 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
120120 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
121121 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
122− terminalLayout: "single",
123− splitTerminalIds: [],
122+ terminalGroups: [
123+ {
124+ id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
125+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
126+ },
127+ ],
128+ activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
124129 session: null,
125130 messages: [],
126131 events: [],
apps/web/src/components/ThreadTerminalDrawer.tsx+270−138
@@ -1,4 +1,5 @@
11 import { FitAddon } from "@xterm/addon-fit";
2+import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react";
23 import { type NativeApi } from "@t3tools/contracts";
34 import { Terminal, type ITheme } from "@xterm/xterm";
45 import {
@@ -19,7 +20,7 @@ import { isTerminalClearShortcut } from "../terminal-shortcuts"; · 14 unmodified lines
1920 import {
2021 DEFAULT_THREAD_TERMINAL_HEIGHT,
2122 DEFAULT_THREAD_TERMINAL_ID,
22− type ThreadTerminalLayout,
23+ type ThreadTerminalGroup,
2324 } from "../types";
2425
2526 const MIN_DRAWER_HEIGHT = 180;
@@ -415,8 +416,8 @@ interface ThreadTerminalDrawerProps { · 389 unmodified lines
415416 height: number;
416417 terminalIds: string[];
417418 activeTerminalId: string;
418− terminalLayout: ThreadTerminalLayout;
419− splitTerminalIds: string[];
419+ terminalGroups: ThreadTerminalGroup[];
420+ activeTerminalGroupId: string;
420421 focusRequestId: number;
421422 onSplitTerminal: () => void;
422423 onNewTerminal: () => void;
@@ -432,8 +433,8 @@ export default function ThreadTerminalDrawer({ · 9 unmodified lines
432433 height,
433434 terminalIds,
434435 activeTerminalId,
435− terminalLayout,
436− splitTerminalIds,
436+ terminalGroups,
437+ activeTerminalGroupId,
437438 focusRequestId,
438439 onSplitTerminal,
439440 onNewTerminal,
@@ -462,28 +463,88 @@ export default function ThreadTerminalDrawer({ · 22 unmodified lines
462463 ? activeTerminalId
463464 : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
464465
465− const resolvedSplitTerminalIds = useMemo(() => {
466− if (terminalLayout !== "split") return [];
467− const validSplitIds = [...new Set(splitTerminalIds)].filter((id) =>
468− normalizedTerminalIds.includes(id),
469− );
470− if (validSplitIds.length >= 2) {
471− return validSplitIds.slice(0, 2);
466+ const resolvedTerminalGroups = useMemo(() => {
467+ const validTerminalIdSet = new Set(normalizedTerminalIds);
468+ const assignedTerminalIds = new Set<string>();
469+ const usedGroupIds = new Set<string>();
470+ const nextGroups: ThreadTerminalGroup[] = [];
471+
472+ const assignUniqueGroupId = (groupId: string): string => {
473+ if (!usedGroupIds.has(groupId)) {
474+ usedGroupIds.add(groupId);
475+ return groupId;
476+ }
477+ let suffix = 2;
478+ while (usedGroupIds.has(`${groupId}-${suffix}`)) {
479+ suffix += 1;
480+ }
481+ const uniqueGroupId = `${groupId}-${suffix}`;
482+ usedGroupIds.add(uniqueGroupId);
483+ return uniqueGroupId;
484+ };
485+
486+ for (const terminalGroup of terminalGroups) {
487+ const nextTerminalIds = [
488+ ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)),
489+ ].filter((terminalId) => {
490+ if (!validTerminalIdSet.has(terminalId)) return false;
491+ if (assignedTerminalIds.has(terminalId)) return false;
492+ return true;
493+ });
494+ if (nextTerminalIds.length === 0) continue;
495+
496+ for (const terminalId of nextTerminalIds) {
497+ assignedTerminalIds.add(terminalId);
498+ }
499+
500+ const baseGroupId =
501+ terminalGroup.id.trim().length > 0
502+ ? terminalGroup.id.trim()
503+ : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`;
504+ nextGroups.push({
505+ id: assignUniqueGroupId(baseGroupId),
506+ terminalIds: nextTerminalIds,
507+ });
472508 }
473509
474− const fallbackPartner = normalizedTerminalIds.find((id) => id !== resolvedActiveTerminalId);
475− if (!fallbackPartner) {
476− return [];
510+ for (const terminalId of normalizedTerminalIds) {
511+ if (assignedTerminalIds.has(terminalId)) continue;
512+ nextGroups.push({
513+ id: assignUniqueGroupId(`group-${terminalId}`),
514+ terminalIds: [terminalId],
515+ });
477516 }
478− return [resolvedActiveTerminalId, fallbackPartner];
479− }, [normalizedTerminalIds, resolvedActiveTerminalId, splitTerminalIds, terminalLayout]);
480517
481− const visibleTerminalIds =
482− terminalLayout === "split" && resolvedSplitTerminalIds.length === 2
483− ? resolvedSplitTerminalIds
484− : [resolvedActiveTerminalId];
518+ if (nextGroups.length > 0) {
519+ return nextGroups;
520+ }
485521
486− const showTabBar = terminalLayout === "tabs" && normalizedTerminalIds.length > 1;
522+ return [
523+ {
524+ id: `group-${resolvedActiveTerminalId}`,
525+ terminalIds: [resolvedActiveTerminalId],
526+ },
527+ ];
528+ }, [normalizedTerminalIds, resolvedActiveTerminalId, terminalGroups]);
529+
530+ const resolvedActiveGroupIndex = useMemo(() => {
531+ const indexById = resolvedTerminalGroups.findIndex(
532+ (terminalGroup) => terminalGroup.id === activeTerminalGroupId,
533+ );
534+ if (indexById >= 0) return indexById;
535+ const indexByTerminal = resolvedTerminalGroups.findIndex((terminalGroup) =>
536+ terminalGroup.terminalIds.includes(resolvedActiveTerminalId),
537+ );
538+ return indexByTerminal >= 0 ? indexByTerminal : 0;
539+ }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]);
540+
541+ const visibleTerminalIds =
542+ resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? [resolvedActiveTerminalId];
543+ const hasTerminalSidebar = normalizedTerminalIds.length > 1;
544+ const isSplitView = visibleTerminalIds.length > 1;
545+ const showGroupHeaders =
546+ resolvedTerminalGroups.length > 1 ||
547+ resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
487548 const terminalLabelById = useMemo(
488549 () =>
489550 new Map(
@@ -585,147 +646,218 @@ export default function ThreadTerminalDrawer({ · 95 unmodified lines
585646
586647 return (
587648 <aside
588− className="thread-terminal-drawer flex shrink-0 flex-col border-t border-border/80 bg-background"
649+ className="thread-terminal-drawer relative flex shrink-0 flex-col border-t border-border/80 bg-background"
589650 style={{ height: `${drawerHeight}px` }}
590651 >
591652 <div
592− className="flex h-2 cursor-row-resize items-center justify-center"
653+ className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize"
593654 onPointerDown={handleResizePointerDown}
594655 onPointerMove={handleResizePointerMove}
595656 onPointerUp={handleResizePointerEnd}
596657 onPointerCancel={handleResizePointerEnd}
597− >
598− <div className="h-px w-10 rounded-full bg-border/80" />
599− </div>
658+ />
659+
660+ {!hasTerminalSidebar && (
661+ <div className="pointer-events-none absolute right-2 top-2 z-20">
662+ <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70">
663+ <button
664+ type="button"
665+ className="p-1 text-foreground/90 transition-colors hover:bg-accent"
666+ onClick={onSplitTerminal}
667+ aria-label="Split Terminal"
668+ title="Split Terminal"
669+ >
670+ <SquareSplitHorizontal className="size-3.25" />
671+ </button>
672+ <div className="h-4 w-px bg-border/80" />
673+ <button
674+ type="button"
675+ className="p-1 text-foreground/90 transition-colors hover:bg-accent"
676+ onClick={onNewTerminal}
677+ aria-label="New Terminal"
678+ title="New Terminal"
679+ >
680+ <Plus className="size-3.25" />
681+ </button>
682+ <div className="h-4 w-px bg-border/80" />
683+ <button
684+ type="button"
685+ className="p-1 text-foreground/90 transition-colors hover:bg-accent"
686+ onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
687+ aria-label="Close Terminal"
688+ title="Close Terminal"
689+ >
690+ <Trash2 className="size-3.25" />
691+ </button>
692+ </div>
693+ </div>
694+ )}
600695
601− <div className="flex items-center gap-2 px-2 pb-1">
602− {showTabBar ? (
603− <div className="min-w-0 flex-1 overflow-x-auto">
604− <div className="inline-flex rounded-md border border-border/80 bg-muted/40 p-0.5">
605− {normalizedTerminalIds.map((terminalId) => {
606− const isActive = terminalId === resolvedActiveTerminalId;
607− return (
696+ <div className="min-h-0 w-full flex-1">
697+ <div className={`flex h-full min-h-0 ${hasTerminalSidebar ? "gap-1.5" : ""}`}>
698+ <div className="min-w-0 flex-1">
699+ {isSplitView ? (
700+ <div
701+ className="grid h-full w-full gap-0 overflow-x-auto"
702+ style={{
703+ gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(260px, 1fr))`,
704+ }}
705+ >
706+ {visibleTerminalIds.map((terminalId) => (
608707 <div
609708 key={terminalId}
610− className={`inline-flex items-center gap-1 rounded px-2.5 py-1 text-xs transition-colors ${
611− isActive
612− ? "bg-background text-foreground shadow-sm"
613− : "text-muted-foreground"
709+ className={`min-h-0 min-w-0 border-l first:border-l-0 ${
710+ terminalId === resolvedActiveTerminalId
711+ ? "border-border"
712+ : "border-border/70"
614713 }`}
714+ onMouseDown={() => {
715+ if (terminalId !== resolvedActiveTerminalId) {
716+ onActiveTerminalChange(terminalId);
717+ }
718+ }}
615719 >
616− <button
617− type="button"
618− className="pr-1 hover:text-foreground"
619− onClick={() => onActiveTerminalChange(terminalId)}
620− >
621− {terminalLabelById.get(terminalId) ?? "Terminal"}
622− </button>
623− <button
624− type="button"
625− className="rounded px-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
626− onClick={(event) => {
627− event.stopPropagation();
628− onCloseTerminal(terminalId);
629− }}
630− aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
631− title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
632− >
633− ×
634− </button>
720+ <div className="h-full p-1">
721+ <TerminalViewport
722+ api={api}
723+ threadId={threadId}
724+ terminalId={terminalId}
725+ cwd={cwd}
726+ focusRequestId={focusRequestId}
727+ autoFocus={terminalId === resolvedActiveTerminalId}
728+ resizeEpoch={resizeEpoch}
729+ drawerHeight={drawerHeight}
730+ />
731+ </div>
635732 </div>
636− );
637− })}
638− </div>
733+ ))}
734+ </div>
735+ ) : (
736+ <div className="h-full p-1">
737+ <TerminalViewport
738+ key={resolvedActiveTerminalId}
739+ api={api}
740+ threadId={threadId}
741+ terminalId={resolvedActiveTerminalId}
742+ cwd={cwd}
743+ focusRequestId={focusRequestId}
744+ autoFocus
745+ resizeEpoch={resizeEpoch}
746+ drawerHeight={drawerHeight}
747+ />
748+ </div>
749+ )}
639750 </div>
640− ) : (
641− <div className="flex-1" />
642− )}
643−
644− <div className="inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-muted/30">
645− <button
646− type="button"
647− className="px-2.5 py-1 text-xs text-foreground/90 transition-colors hover:bg-accent"
648− onClick={onSplitTerminal}
649− >
650− Split Terminal
651− </button>
652− <div className="h-5 w-px bg-border/80" />
653− <button
654− type="button"
655− className="px-2.5 py-1 text-xs text-foreground/90 transition-colors hover:bg-accent"
656− onClick={onNewTerminal}
657− >
658− New Terminal
659− </button>
660− <div className="h-5 w-px bg-border/80" />
661− <button
662− type="button"
663− className="px-2.5 py-1 text-xs text-foreground/90 transition-colors hover:bg-accent"
664− onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
665− >
666− Close Terminal
667− </button>
668− </div>
669− </div>
670751
671− <div className="min-h-0 w-full flex-1 px-1.5 pb-1">
672− {terminalLayout === "split" && visibleTerminalIds.length === 2 ? (
673− <div className="grid h-full w-full grid-cols-2 gap-1">
674− {visibleTerminalIds.map((terminalId) => (
675− <div
676− key={terminalId}
677− className="flex min-h-0 flex-col rounded-[4px] border border-border/70"
678− >
679− <div className="flex items-center justify-between border-b border-border/70 px-2 py-1">
752+ {hasTerminalSidebar && (
753+ <aside className="flex w-36 min-w-36 flex-col border border-border/70 bg-muted/10">
754+ <div className="flex h-[22px] items-stretch justify-end border-b border-border/70">
755+ <div className="inline-flex h-full items-stretch">
680756 <button
681757 type="button"
682− className={`text-xs ${
683− terminalId === resolvedActiveTerminalId
684− ? "text-foreground"
685− : "text-muted-foreground hover:text-foreground"
686− }`}
687− onClick={() => onActiveTerminalChange(terminalId)}
758+ className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70"
759+ onClick={onSplitTerminal}
760+ aria-label="Split Terminal"
761+ title="Split Terminal"
688762 >
689− {terminalLabelById.get(terminalId) ?? "Terminal"}
763+ <SquareSplitHorizontal className="size-3.25" />
690764 </button>
691765 <button
692766 type="button"
693− className="rounded px-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
694− onClick={() => onCloseTerminal(terminalId)}
695− aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
696− title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
767+ className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
768+ onClick={onNewTerminal}
769+ aria-label="New Terminal"
770+ title="New Terminal"
697771 >
698− ×
772+ <Plus className="size-3.25" />
773+ </button>
774+ <button
775+ type="button"
776+ className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
777+ onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
778+ aria-label="Close Terminal"
779+ title="Close Terminal"
780+ >
781+ <Trash2 className="size-3.25" />
699782 </button>
700− </div>
701− <div className="min-h-0 flex-1">
702− <TerminalViewport
703− api={api}
704− threadId={threadId}
705− terminalId={terminalId}
706− cwd={cwd}
707− focusRequestId={focusRequestId}
708− autoFocus={terminalId === resolvedActiveTerminalId}
709− resizeEpoch={resizeEpoch}
710− drawerHeight={drawerHeight}
711− />
712783 </div>
713784 </div>
714− ))}
715− </div>
716− ) : (
717− <TerminalViewport
718− key={resolvedActiveTerminalId}
719− api={api}
720− threadId={threadId}
721− terminalId={resolvedActiveTerminalId}
722− cwd={cwd}
723− focusRequestId={focusRequestId}
724− autoFocus
725− resizeEpoch={resizeEpoch}
726− drawerHeight={drawerHeight}
727− />
728− )}
785+
786+ <div className="min-h-0 flex-1 overflow-y-auto px-1 py-1">
787+ {resolvedTerminalGroups.map((terminalGroup, groupIndex) => {
788+ const isGroupActive = terminalGroup.terminalIds.includes(resolvedActiveTerminalId);
789+ const groupActiveTerminalId = isGroupActive
790+ ? resolvedActiveTerminalId
791+ : (terminalGroup.terminalIds[0] ?? resolvedActiveTerminalId);
792+
793+ return (
794+ <div key={terminalGroup.id} className="pb-0.5">
795+ {showGroupHeaders && (
796+ <button
797+ type="button"
798+ className={`flex w-full items-center rounded px-1 py-0.5 text-[10px] uppercase tracking-[0.08em] ${
799+ isGroupActive
800+ ? "bg-accent/70 text-foreground"
801+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
802+ }`}
803+ onClick={() => onActiveTerminalChange(groupActiveTerminalId)}
804+ >
805+ {terminalGroup.terminalIds.length > 1
806+ ? `Split ${groupIndex + 1}`
807+ : `Terminal ${groupIndex + 1}`}
808+ </button>
809+ )}
810+
811+ <div className={showGroupHeaders ? "ml-1 border-l border-border/60 pl-1.5" : ""}>
812+ {terminalGroup.terminalIds.map((terminalId) => {
813+ const isActive = terminalId === resolvedActiveTerminalId;
814+ return (
815+ <div
816+ key={terminalId}
817+ className={`group flex items-center gap-1 rounded px-1 py-0.5 text-[11px] ${
818+ isActive
819+ ? "bg-accent text-foreground"
820+ : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
821+ }`}
822+ >
823+ {showGroupHeaders && (
824+ <span className="text-[10px] text-muted-foreground/80">└</span>
825+ )}
826+ <button
827+ type="button"
828+ className="flex min-w-0 flex-1 items-center gap-1 text-left"
829+ onClick={() => onActiveTerminalChange(terminalId)}
830+ >
831+ <TerminalSquare className="size-3 shrink-0" />
832+ <span className="truncate">
833+ {terminalLabelById.get(terminalId) ?? "Terminal"}
834+ </span>
835+ </button>
836+ {normalizedTerminalIds.length > 1 && (
837+ <button
838+ type="button"
839+ className="rounded px-1 text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100"
840+ onClick={(event) => {
841+ event.stopPropagation();
842+ onCloseTerminal(terminalId);
843+ }}
844+ aria-label={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
845+ title={`Close ${terminalLabelById.get(terminalId) ?? "terminal"}`}
846+ >
847+ ×
848+ </button>
849+ )}
850+ </div>
851+ );
852+ })}
853+ </div>
854+ </div>
855+ );
856+ })}
857+ </div>
858+ </aside>
859+ )}
860+ </div>
729861 </div>
730862 </aside>
731863 );
apps/web/src/persistenceSchema.test.ts+82−14
@@ -52,8 +52,12 @@ describe("hydratePersistedState", () => { · 51 unmodified lines
5252 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
5353 expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
5454 expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
55− expect(hydrated?.threads[0]?.terminalLayout).toBe("single");
56− expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]);
55+ expect(hydrated?.threads[0]?.terminalGroups).toEqual([
56+ { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
57+ ]);
58+ expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(
59+ `group-${DEFAULT_THREAD_TERMINAL_ID}`,
60+ );
5761 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);
5862 expect(hydrated?.runtimeMode).toBe("full-access");
5963 });
@@ -98,8 +102,12 @@ describe("hydratePersistedState", () => { · 38 unmodified lines
98102 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
99103 expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
100104 expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
101− expect(hydrated?.threads[0]?.terminalLayout).toBe("single");
102− expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]);
105+ expect(hydrated?.threads[0]?.terminalGroups).toEqual([
106+ { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
107+ ]);
108+ expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(
109+ `group-${DEFAULT_THREAD_TERMINAL_ID}`,
110+ );
103111 expect(hydrated?.activeThreadId).toBe("t-1");
104112 expect(hydrated?.runtimeMode).toBe("full-access");
105113 });
@@ -125,7 +133,7 @@ describe("hydratePersistedState", () => { · 19 unmodified lines
125133 expect(hydrated?.runtimeMode).toBe("approval-required");
126134 });
127135
128− it("hydrates terminal fields from v6 payload", () => {
136+ it("hydrates terminal fields from legacy v6 payload", () => {
129137 const payload = JSON.stringify({
130138 version: 6,
131139 runtimeMode: "full-access",
@@ -164,11 +172,60 @@ describe("hydratePersistedState", () => { · 32 unmodified lines
164172 expect(hydrated?.threads[0]?.terminalHeight).toBe(360);
165173 expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]);
166174 expect(hydrated?.threads[0]?.activeTerminalId).toBe("term-2");
167− expect(hydrated?.threads[0]?.terminalLayout).toBe("tabs");
168− expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]);
175+ expect(hydrated?.threads[0]?.terminalGroups).toEqual([
176+ { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
177+ { id: "group-term-2", terminalIds: ["term-2"] },
178+ ]);
179+ expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe("group-term-2");
169180 expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z");
170181 });
171182
183+ it("hydrates legacy split layout into a grouped split", () => {
184+ const payload = JSON.stringify({
185+ version: 6,
186+ runtimeMode: "full-access",
187+ projects: [
188+ {
189+ id: "p-1",
190+ name: "Project",
191+ cwd: "/tmp/project",
192+ model: "gpt-5.3-codex",
193+ expanded: true,
194+ },
195+ ],
196+ threads: [
197+ {
198+ id: "t-1",
199+ codexThreadId: null,
200+ projectId: "p-1",
201+ title: "Thread",
202+ model: "gpt-5.3-codex",
203+ terminalOpen: true,
204+ terminalHeight: 360,
205+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"],
206+ activeTerminalId: "term-2",
207+ terminalLayout: "split",
208+ splitTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
209+ messages: [],
210+ createdAt: "2026-02-08T10:00:00.000Z",
211+ },
212+ ],
213+ activeThreadId: "t-1",
214+ });
215+
216+ const hydrated = hydratePersistedState(payload, false);
217+ expect(hydrated?.threads[0]?.terminalGroups).toEqual([
218+ {
219+ id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
220+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
221+ },
222+ { id: "group-term-3", terminalIds: ["term-3"] },
223+ ]);
224+ expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(
225+ `group-${DEFAULT_THREAD_TERMINAL_ID}`,
226+ );
227+ });
228+
172229 it("defaults terminalHeight when hydrating v5 payloads", () => {
173230 const payload = JSON.stringify({
174231 version: 5,
@@ -201,12 +258,17 @@ describe("hydratePersistedState", () => { · 26 unmodified lines
201258 expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT);
202259 expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
203260 expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
204− expect(hydrated?.threads[0]?.terminalLayout).toBe("single");
261+ expect(hydrated?.threads[0]?.terminalGroups).toEqual([
262+ { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
263+ ]);
264+ expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(
265+ `group-${DEFAULT_THREAD_TERMINAL_ID}`,
266+ );
205267 });
206268 });
207269
208270 describe("toPersistedState", () => {
209− it("writes v6 payload and strips non-persisted thread fields", () => {
271+ it("writes v7 payload and strips non-persisted thread fields", () => {
210272 const thread: Thread = {
211273 id: "t-1",
212274 codexThreadId: "thr_1",
@@ -217,8 +279,11 @@ describe("toPersistedState", () => { · 4 unmodified lines
217279 terminalHeight: 320,
218280 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
219281 activeTerminalId: "term-2",
220− terminalLayout: "tabs",
221− splitTerminalIds: [],
282+ terminalGroups: [
283+ { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
284+ { id: "group-term-2", terminalIds: ["term-2"] },
285+ ],
286+ activeTerminalGroupId: "group-term-2",
222287 session: null,
223288 messages: [
224289 {
@@ -262,7 +327,7 @@ describe("toPersistedState", () => { · 37 unmodified lines
262327 runtimeMode: "full-access",
263328 });
264329
265− expect(persisted.version).toBe(6);
330+ expect(persisted.version).toBe(7);
266331 expect(persisted.runtimeMode).toBe("full-access");
267332 expect(persisted.threads[0]).toEqual({
268333 id: "t-1",
@@ -274,8 +339,11 @@ describe("toPersistedState", () => { · 5 unmodified lines
274339 terminalHeight: 320,
275340 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
276341 activeTerminalId: "term-2",
277− terminalLayout: "tabs",
278− splitTerminalIds: [],
342+ terminalGroups: [
343+ { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] },
344+ { id: "group-term-2", terminalIds: ["term-2"] },
345+ ],
346+ activeTerminalGroupId: "group-term-2",
279347 messages: [
280348 {
281349 id: "m-1",
apps/web/src/persistenceSchema.ts+99−26
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 type Project,
99 type RuntimeMode,
1010 type Thread,
11+ type ThreadTerminalGroup,
1112 } from "./types";
1213
1314 const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
@@ -39,6 +40,11 @@ const persistedMessageSchema = z.object({ · 25 unmodified lines
3940 streaming: z.boolean(),
4041 });
4142
43+const persistedTerminalGroupSchema = z.object({
44+ id: z.string().trim().min(1),
45+ terminalIds: z.array(z.string().trim().min(1)),
46+});
47+
4248 const persistedThreadSchema = z.object({
4349 id: z.string().min(1),
4450 codexThreadId: z.string().min(1).nullable().default(null),
@@ -51,8 +57,11 @@ const persistedThreadSchema = z.object({ · 6 unmodified lines
5157 ),
5258 terminalIds: z.array(z.string().trim().min(1)).default([DEFAULT_THREAD_TERMINAL_ID]),
5359 activeTerminalId: z.string().trim().min(1).default(DEFAULT_THREAD_TERMINAL_ID),
54− terminalLayout: z.enum(["single", "split", "tabs"]).default("single"),
55− splitTerminalIds: z.array(z.string().trim().min(1)).default([]),
60+ terminalGroups: z.array(persistedTerminalGroupSchema).default([]),
61+ activeTerminalGroupId: z.string().trim().min(1).optional(),
62+ // Legacy v6 and older fields retained for migration.
63+ terminalLayout: z.enum(["single", "split", "tabs"]).optional(),
64+ splitTerminalIds: z.array(z.string().trim().min(1)).optional(),
5665 messages: z.array(persistedMessageSchema),
5766 createdAt: z.string().min(1),
5867 lastVisitedAt: z.string().min(1).optional(),
@@ -91,12 +100,18 @@ export const persistedStateV6Schema = persistedStateBodySchema.extend({ · 32 unmodified lines
91100 version: z.literal(6).optional(),
92101 });
93102
103+export const persistedStateV7Schema = persistedStateBodySchema.extend({
104+ runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
105+ version: z.literal(7).optional(),
106+});
107+
94108 export const persistedStateV5Schema = persistedStateBodySchema.extend({
95109 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
96110 version: z.literal(5).optional(),
97111 });
98112
99113 const persistedStateSchema = z.union([
114+ persistedStateV7Schema,
100115 persistedStateV6Schema,
101116 persistedStateV5Schema,
102117 persistedStateV4Schema,
@@ -134,33 +149,91 @@ function hydrateThread( · 31 unmodified lines
134149 thread: z.infer<typeof persistedThreadSchema>,
135150 isLegacyPayload: boolean,
136151 ): Thread {
137− const terminalIds = [...new Set(thread.terminalIds)]
138− .map((id) => id.trim())
139− .filter((id) => id.length > 0);
152+ const terminalIds = [...new Set(thread.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
140153 const safeTerminalIds =
141154 terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID];
142155 const activeTerminalId = safeTerminalIds.includes(thread.activeTerminalId)
143156 ? thread.activeTerminalId
144157 : (safeTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
145− let terminalLayout = thread.terminalLayout;
146− let splitTerminalIds = [...new Set(thread.splitTerminalIds)].filter((id) =>
147− safeTerminalIds.includes(id),
148− );
149− if (terminalLayout === "split") {
150− splitTerminalIds = splitTerminalIds.slice(0, 2);
151− if (splitTerminalIds.length < 2) {
152− terminalLayout = safeTerminalIds.length > 1 ? "tabs" : "single";
153− splitTerminalIds = [];
158+ const safeTerminalIdSet = new Set(safeTerminalIds);
159+ const assignedTerminalIds = new Set<string>();
160+ const usedGroupIds = new Set<string>();
161+ const normalizedGroups: ThreadTerminalGroup[] = [];
162+ const assignUniqueGroupId = (groupId: string): string => {
163+ if (!usedGroupIds.has(groupId)) {
164+ usedGroupIds.add(groupId);
165+ return groupId;
166+ }
167+ let suffix = 2;
168+ while (usedGroupIds.has(`${groupId}-${suffix}`)) {
169+ suffix += 1;
170+ }
171+ const uniqueGroupId = `${groupId}-${suffix}`;
172+ usedGroupIds.add(uniqueGroupId);
173+ return uniqueGroupId;
174+ };
175+
176+ for (const terminalGroup of thread.terminalGroups) {
177+ const nextTerminalIds = [
178+ ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)),
179+ ].filter((terminalId) => {
180+ if (!safeTerminalIdSet.has(terminalId)) return false;
181+ if (assignedTerminalIds.has(terminalId)) return false;
182+ return true;
183+ });
184+ if (nextTerminalIds.length === 0) continue;
185+ for (const terminalId of nextTerminalIds) {
186+ assignedTerminalIds.add(terminalId);
187+ }
188+ const baseGroupId =
189+ terminalGroup.id.trim().length > 0
190+ ? terminalGroup.id.trim()
191+ : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`;
192+ normalizedGroups.push({
193+ id: assignUniqueGroupId(baseGroupId),
194+ terminalIds: nextTerminalIds,
195+ });
196+ }
197+
198+ if (normalizedGroups.length === 0 && thread.terminalLayout === "split") {
199+ const splitTerminalIds = [
200+ ...new Set((thread.splitTerminalIds ?? []).map((id) => id.trim()).filter((id) => id.length > 0)),
201+ ].filter((terminalId) => safeTerminalIdSet.has(terminalId));
202+ if (splitTerminalIds.length >= 2) {
203+ const splitGroupTerminalIds = splitTerminalIds.slice(0, 2);
204+ for (const terminalId of splitGroupTerminalIds) {
205+ assignedTerminalIds.add(terminalId);
206+ }
207+ normalizedGroups.push({
208+ id: assignUniqueGroupId(`group-${splitGroupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`),
209+ terminalIds: splitGroupTerminalIds,
210+ });
154211 }
155− } else {
156− splitTerminalIds = [];
157212 }
158− if (terminalLayout === "single" && safeTerminalIds.length > 1) {
159− terminalLayout = "tabs";
160− } else if (terminalLayout === "tabs" && safeTerminalIds.length < 2) {
161− terminalLayout = "single";
213+
214+ for (const terminalId of safeTerminalIds) {
215+ if (assignedTerminalIds.has(terminalId)) continue;
216+ normalizedGroups.push({
217+ id: assignUniqueGroupId(`group-${terminalId}`),
218+ terminalIds: [terminalId],
219+ });
162220 }
163221
222+ const activeGroupIndexFromId = normalizedGroups.findIndex(
223+ (terminalGroup) => terminalGroup.id === thread.activeTerminalGroupId,
224+ );
225+ const activeGroupIndexFromTerminal = normalizedGroups.findIndex((terminalGroup) =>
226+ terminalGroup.terminalIds.includes(activeTerminalId),
227+ );
228+ const activeGroupIndex =
229+ activeGroupIndexFromId >= 0
230+ ? activeGroupIndexFromId
231+ : (activeGroupIndexFromTerminal >= 0 ? activeGroupIndexFromTerminal : 0);
232+ const activeTerminalGroupId =
233+ normalizedGroups[activeGroupIndex]?.id ??
234+ normalizedGroups[0]?.id ??
235+ `group-${DEFAULT_THREAD_TERMINAL_ID}`;
236+
164237 return {
165238 id: thread.id,
166239 codexThreadId: thread.codexThreadId,
@@ -171,8 +244,8 @@ function hydrateThread( · 4 unmodified lines
171244 terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
172245 terminalIds: safeTerminalIds,
173246 activeTerminalId,
174− terminalLayout,
175− splitTerminalIds,
247+ terminalGroups: normalizedGroups,
248+ activeTerminalGroupId,
176249 session: null,
177250 messages: thread.messages.map((message) => {
178251 const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment }));
@@ -235,9 +308,9 @@ export function hydratePersistedState( · 56 unmodified lines
235308
236309 export function toPersistedState(
237310 state: PersistedStoreSnapshot,
238−): z.infer<typeof persistedStateV6Schema> {
311+): z.infer<typeof persistedStateV7Schema> {
239312 return {
240− version: 6,
313+ version: 7,
241314 projects: state.projects,
242315 threads: state.threads.map((thread) => ({
243316 id: thread.id,
@@ -249,8 +322,8 @@ export function toPersistedState( · 5 unmodified lines
249322 terminalHeight: thread.terminalHeight,
250323 terminalIds: thread.terminalIds,
251324 activeTerminalId: thread.activeTerminalId,
252− terminalLayout: thread.terminalLayout,
253− splitTerminalIds: thread.splitTerminalIds,
325+ terminalGroups: thread.terminalGroups,
326+ activeTerminalGroupId: thread.activeTerminalGroupId,
254327 messages: thread.messages.map((message) => ({
255328 id: message.id,
256329 role: message.role,
apps/web/src/store.test.ts+92−14
@@ -39,8 +39,13 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { · 38 unmodified lines
3939 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
4040 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
4141 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
42− terminalLayout: "single",
43− splitTerminalIds: [],
42+ terminalGroups: [
43+ {
44+ id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
45+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
46+ },
47+ ],
48+ activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
4449 session: makeSession(),
4550 messages: [],
4651 events: [],
@@ -123,10 +128,15 @@ describe("store reducer thread continuity", () => { · 76 unmodified lines
123128 terminalId: "term-2",
124129 });
125130
126− expect(next.threads[0]?.terminalLayout).toBe("split");
127131 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]);
128− expect(next.threads[0]?.splitTerminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]);
129132 expect(next.threads[0]?.activeTerminalId).toBe("term-2");
133+ expect(next.threads[0]?.terminalGroups).toEqual([
134+ {
135+ id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
136+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
137+ },
138+ ]);
139+ expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
130140 });
131141
132142 it("creates a new full-width terminal and switches to tab mode", () => {
@@ -137,18 +147,31 @@ describe("store reducer thread continuity", () => { · 4 unmodified lines
137147 terminalId: "term-2",
138148 });
139149
140− expect(next.threads[0]?.terminalLayout).toBe("tabs");
141150 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]);
142151 expect(next.threads[0]?.activeTerminalId).toBe("term-2");
143− expect(next.threads[0]?.splitTerminalIds).toEqual([]);
152+ expect(next.threads[0]?.terminalGroups).toEqual([
153+ {
154+ id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
155+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
156+ },
157+ { id: "group-term-2", terminalIds: ["term-2"] },
158+ ]);
159+ expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-2");
144160 });
145161
146− it("switches the active tab terminal", () => {
162+ it("switches the active terminal and restores its owning group", () => {
147163 const state = makeState(
148164 makeThread({
149− terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
165+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"],
150166 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
151− terminalLayout: "tabs",
167+ terminalGroups: [
168+ {
169+ id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
170+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
171+ },
172+ { id: "group-term-3", terminalIds: ["term-3"] },
173+ ],
174+ activeTerminalGroupId: "group-term-3",
152175 }),
153176 );
154177 const next = reducer(state, {
@@ -158,14 +181,57 @@ describe("store reducer thread continuity", () => { · 3 unmodified lines
158181 });
159182
160183 expect(next.threads[0]?.activeTerminalId).toBe("term-2");
184+ expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
161185 });
162186
163− it("closes a terminal tab and falls back to single layout when one remains", () => {
187+ it("supports splitting beyond two terminals in the same group", () => {
164188 const state = makeState(
165189 makeThread({
166190 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
167191 activeTerminalId: "term-2",
168− terminalLayout: "tabs",
192+ terminalGroups: [
193+ {
194+ id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
195+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
196+ },
197+ ],
198+ activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
199+ }),
200+ );
201+ const next = reducer(state, {
202+ type: "SPLIT_THREAD_TERMINAL",
203+ threadId: "thread-local-1",
204+ terminalId: "term-3",
205+ });
206+
207+ expect(next.threads[0]?.terminalIds).toEqual([
208+ DEFAULT_THREAD_TERMINAL_ID,
209+ "term-2",
210+ "term-3",
211+ ]);
212+ expect(next.threads[0]?.terminalGroups).toEqual([
213+ {
214+ id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
215+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"],
216+ },
217+ ]);
218+ expect(next.threads[0]?.activeTerminalId).toBe("term-3");
219+ expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
220+ });
221+
222+ it("closes a terminal and keeps grouped layout coherent", () => {
223+ const state = makeState(
224+ makeThread({
225+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"],
226+ activeTerminalId: "term-2",
227+ terminalGroups: [
228+ {
229+ id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
230+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"],
231+ },
232+ { id: "group-term-3", terminalIds: ["term-3"] },
233+ ],
234+ activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
169235 }),
170236 );
171237 const next = reducer(state, {
@@ -174,9 +240,15 @@ describe("store reducer thread continuity", () => { · 2 unmodified lines
174240 terminalId: "term-2",
175241 });
176242
177− expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
243+ expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-3"]);
178244 expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
179− expect(next.threads[0]?.terminalLayout).toBe("single");
245+ expect(next.threads[0]?.terminalGroups).toEqual([
246+ {
247+ id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
248+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
249+ },
250+ { id: "group-term-3", terminalIds: ["term-3"] },
251+ ]);
180252 });
181253
182254 it("closes the final terminal and hides the drawer", () => {
@@ -193,7 +265,13 @@ describe("store reducer thread continuity", () => { · 10 unmodified lines
193265
194266 expect(next.threads[0]?.terminalOpen).toBe(false);
195267 expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]);
196− expect(next.threads[0]?.terminalLayout).toBe("single");
268+ expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID);
269+ expect(next.threads[0]?.terminalGroups).toEqual([
270+ {
271+ id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
272+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
273+ },
274+ ]);
197275 });
198276
199277 it("backfills codexThreadId from routed provider events", () => {
apps/web/src/store.ts+217−75
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
1919 type Project,
2020 type RuntimeMode,
2121 type Thread,
22+ type ThreadTerminalGroup,
2223 } from "./types";
2324
2425 // ── Actions ──────────────────────────────────────────────────────────
@@ -72,8 +73,9 @@ export interface AppState { · 47 unmodified lines
7273 diffOpen: boolean;
7374 }
7475
75−const PERSISTED_STATE_KEY = "t3code:renderer-state:v6";
76+const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";
7677 const LEGACY_PERSISTED_STATE_KEYS = [
78+ "t3code:renderer-state:v6",
7779 "t3code:renderer-state:v5",
7880 "t3code:renderer-state:v4",
7981 "t3code:renderer-state:v3",
@@ -139,9 +141,7 @@ function updateThread( · 59 unmodified lines
139141 }
140142
141143 function normalizeTerminalIds(terminalIds: string[]): string[] {
142− const ids = terminalIds
143− .map((id) => id.trim())
144− .filter((id) => id.length > 0);
144+ const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
145145 const unique = [...new Set(ids)];
146146 if (unique.length > 0) {
147147 return unique;
@@ -149,44 +149,112 @@ function normalizeTerminalIds(terminalIds: string[]): string[] { · 1 unmodified line
149149 return [DEFAULT_THREAD_TERMINAL_ID];
150150 }
151151
152+function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
153+ return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
154+}
155+
156+function fallbackGroupId(terminalId: string): string {
157+ return `group-${terminalId}`;
158+}
159+
160+function assignUniqueGroupId(groupId: string, usedGroupIds: Set<string>): string {
161+ if (!usedGroupIds.has(groupId)) {
162+ usedGroupIds.add(groupId);
163+ return groupId;
164+ }
165+ let suffix = 2;
166+ while (usedGroupIds.has(`${groupId}-${suffix}`)) {
167+ suffix += 1;
168+ }
169+ const uniqueGroupId = `${groupId}-${suffix}`;
170+ usedGroupIds.add(uniqueGroupId);
171+ return uniqueGroupId;
172+}
173+
174+function normalizeTerminalGroups(
175+ thread: Thread,
176+ terminalIds: string[],
177+): ThreadTerminalGroup[] {
178+ const validTerminalIdSet = new Set(terminalIds);
179+ const assignedTerminalIds = new Set<string>();
180+ const usedGroupIds = new Set<string>();
181+ const groups: ThreadTerminalGroup[] = [];
182+
183+ for (const group of thread.terminalGroups) {
184+ const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => {
185+ if (!validTerminalIdSet.has(terminalId)) return false;
186+ if (assignedTerminalIds.has(terminalId)) return false;
187+ return true;
188+ });
189+ if (groupTerminalIds.length === 0) continue;
190+ for (const terminalId of groupTerminalIds) {
191+ assignedTerminalIds.add(terminalId);
192+ }
193+ const baseGroupId =
194+ group.id.trim().length > 0
195+ ? group.id.trim()
196+ : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
197+ groups.push({
198+ id: assignUniqueGroupId(baseGroupId, usedGroupIds),
199+ terminalIds: groupTerminalIds,
200+ });
201+ }
202+
203+ for (const terminalId of terminalIds) {
204+ if (assignedTerminalIds.has(terminalId)) continue;
205+ groups.push({
206+ id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds),
207+ terminalIds: [terminalId],
208+ });
209+ }
210+
211+ if (groups.length > 0) {
212+ return groups;
213+ }
214+
215+ return [
216+ {
217+ id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
218+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
219+ },
220+ ];
221+}
222+
223+function findGroupIndexByTerminalId(
224+ terminalGroups: ThreadTerminalGroup[],
225+ terminalId: string,
226+): number {
227+ return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
228+}
229+
152230 function normalizeThreadTerminals(thread: Thread): Thread {
153231 const terminalIds = normalizeTerminalIds(thread.terminalIds);
154232 const activeTerminalId = terminalIds.includes(thread.activeTerminalId)
155233 ? thread.activeTerminalId
156234 : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
157−
158− if (thread.terminalLayout === "split") {
159− const validSplitIds = [...new Set(thread.splitTerminalIds)].filter((id) =>
160− terminalIds.includes(id),
161− );
162− if (validSplitIds.length >= 2) {
163− const splitTerminalIds = validSplitIds.slice(0, 2);
164− const splitActiveTerminalId = splitTerminalIds.includes(activeTerminalId)
165− ? activeTerminalId
166− : (splitTerminalIds[1] ?? splitTerminalIds[0] ?? activeTerminalId);
167− return {
168− ...thread,
169− terminalIds,
170− activeTerminalId: splitActiveTerminalId,
171− terminalLayout: "split",
172− splitTerminalIds,
173− };
174− }
175− }
176−
177− const terminalLayout =
178− terminalIds.length > 1 && thread.terminalLayout === "single"
179− ? "tabs"
180− : (thread.terminalLayout === "tabs" && terminalIds.length < 2
181− ? "single"
182− : thread.terminalLayout);
235+ const terminalGroups = normalizeTerminalGroups(thread, terminalIds);
236+ const activeGroupIndexFromId = terminalGroups.findIndex(
237+ (group) => group.id === thread.activeTerminalGroupId,
238+ );
239+ const activeGroupIndexFromTerminal = findGroupIndexByTerminalId(
240+ terminalGroups,
241+ activeTerminalId,
242+ );
243+ const activeGroupIndex =
244+ activeGroupIndexFromId >= 0
245+ ? activeGroupIndexFromId
246+ : (activeGroupIndexFromTerminal >= 0 ? activeGroupIndexFromTerminal : 0);
247+ const activeTerminalGroupId =
248+ terminalGroups[activeGroupIndex]?.id ??
249+ terminalGroups[0]?.id ??
250+ fallbackGroupId(activeTerminalId);
183251
184252 return {
185253 ...thread,
186254 terminalIds,
187255 activeTerminalId,
188− terminalLayout,
189− splitTerminalIds: [],
256+ terminalGroups,
257+ activeTerminalGroupId,
190258 };
191259 }
192260
@@ -202,33 +270,47 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { · 9 unmodified lines
202270 terminalOpen: false,
203271 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
204272 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
205− terminalLayout: "single",
206− splitTerminalIds: [],
273+ terminalGroups: [
274+ {
275+ id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
276+ terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
277+ },
278+ ],
279+ activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
207280 });
208281 }
209282
283+ const closedTerminalIndex = thread.terminalIds.indexOf(terminalId);
284+ const closedTerminalGroup = thread.terminalGroups.find((group) =>
285+ group.terminalIds.includes(terminalId),
286+ );
287+ const closedTerminalGroupIndex = closedTerminalGroup
288+ ? closedTerminalGroup.terminalIds.indexOf(terminalId)
289+ : -1;
290+ const remainingTerminalsInClosedGroup = (
291+ closedTerminalGroup?.terminalIds ?? []
292+ ).filter((id) => id !== terminalId);
210293 const nextActiveTerminalId =
211294 thread.activeTerminalId === terminalId
212− ? (remainingTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID)
295+ ? (remainingTerminalsInClosedGroup[
296+ Math.min(closedTerminalGroupIndex, remainingTerminalsInClosedGroup.length - 1)
297+ ] ??
298+ remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??
299+ remainingTerminalIds[0] ??
300+ DEFAULT_THREAD_TERMINAL_ID)
213301 : thread.activeTerminalId;
214− let nextLayout = thread.terminalLayout;
215− let nextSplitTerminalIds = thread.splitTerminalIds.filter((id) => id !== terminalId);
216−
217− if (nextLayout === "split" && nextSplitTerminalIds.length < 2) {
218− nextLayout = remainingTerminalIds.length > 1 ? "tabs" : "single";
219− nextSplitTerminalIds = [];
220− }
221−
222− if (nextLayout === "tabs" && remainingTerminalIds.length < 2) {
223− nextLayout = "single";
224− }
302+ const nextTerminalGroups = thread.terminalGroups
303+ .map((group) => ({
304+ ...group,
305+ terminalIds: group.terminalIds.filter((id) => id !== terminalId),
306+ }))
307+ .filter((group) => group.terminalIds.length > 0);
225308
226309 return normalizeThreadTerminals({
227310 ...thread,
228311 terminalIds: remainingTerminalIds,
229312 activeTerminalId: nextActiveTerminalId,
230− terminalLayout: nextLayout,
231− splitTerminalIds: nextSplitTerminalIds,
313+ terminalGroups: nextTerminalGroups,
232314 });
233315 }
234316
@@ -440,28 +522,67 @@ export function reducer(state: AppState, action: Action): AppState { · 205 unmodified lines
440522 return {
441523 ...state,
442524 threads: updateThread(state.threads, action.threadId, (thread) => {
443− const terminalIds = thread.terminalIds.includes(action.terminalId)
444− ? thread.terminalIds
445− : [...thread.terminalIds, action.terminalId];
446− const firstTerminalId =
447− terminalIds.includes(thread.activeTerminalId)
448− ? thread.activeTerminalId
449− : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
450− const secondTerminalId =
451− action.terminalId === firstTerminalId
452− ? (terminalIds.find((id) => id !== firstTerminalId) ?? firstTerminalId)
453− : action.terminalId;
454− const splitTerminalIds =
455− firstTerminalId === secondTerminalId
456− ? []
457− : [firstTerminalId, secondTerminalId];
525+ const normalizedThread = normalizeThreadTerminals(thread);
526+ const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
527+ ? normalizedThread.terminalIds
528+ : [...normalizedThread.terminalIds, action.terminalId];
529+ const terminalGroups = normalizedThread.terminalGroups.map((group) => ({
530+ ...group,
531+ terminalIds: [...group.terminalIds],
532+ }));
533+ let activeGroupIndex = terminalGroups.findIndex(
534+ (group) => group.id === normalizedThread.activeTerminalGroupId,
535+ );
536+ if (activeGroupIndex < 0) {
537+ activeGroupIndex = findGroupIndexByTerminalId(
538+ terminalGroups,
539+ normalizedThread.activeTerminalId,
540+ );
541+ }
542+ if (activeGroupIndex < 0) {
543+ terminalGroups.push({
544+ id: fallbackGroupId(normalizedThread.activeTerminalId),
545+ terminalIds: [normalizedThread.activeTerminalId],
546+ });
547+ activeGroupIndex = terminalGroups.length - 1;
548+ }
458549
550+ const existingGroupIndex = findGroupIndexByTerminalId(
551+ terminalGroups,
552+ action.terminalId,
553+ );
554+ if (existingGroupIndex >= 0) {
555+ terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[
556+ existingGroupIndex
557+ ]!.terminalIds.filter((id) => id !== action.terminalId);
558+ if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
559+ terminalGroups.splice(existingGroupIndex, 1);
560+ if (existingGroupIndex < activeGroupIndex) {
561+ activeGroupIndex -= 1;
562+ }
563+ }
564+ }
565+
566+ const destinationGroup = terminalGroups[activeGroupIndex];
567+ if (!destinationGroup) {
568+ return normalizedThread;
569+ }
570+ if (!destinationGroup.terminalIds.includes(action.terminalId)) {
571+ const anchorIndex = destinationGroup.terminalIds.indexOf(
572+ normalizedThread.activeTerminalId,
573+ );
574+ if (anchorIndex >= 0) {
575+ destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId);
576+ } else {
577+ destinationGroup.terminalIds.push(action.terminalId);
578+ }
579+ }
459580 return normalizeThreadTerminals({
460− ...thread,
581+ ...normalizedThread,
461582 terminalIds,
462− activeTerminalId: secondTerminalId,
463− terminalLayout: splitTerminalIds.length === 2 ? "split" : "single",
464− splitTerminalIds,
583+ activeTerminalId: action.terminalId,
584+ activeTerminalGroupId: destinationGroup.id,
585+ terminalGroups,
465586 });
466587 }),
467588 };
@@ -470,15 +591,25 @@ export function reducer(state: AppState, action: Action): AppState { · 2 unmodified lines
470591 return {
471592 ...state,
472593 threads: updateThread(state.threads, action.threadId, (thread) => {
473− const terminalIds = thread.terminalIds.includes(action.terminalId)
474− ? thread.terminalIds
475− : [...thread.terminalIds, action.terminalId];
594+ const normalizedThread = normalizeThreadTerminals(thread);
595+ const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
596+ ? normalizedThread.terminalIds
597+ : [...normalizedThread.terminalIds, action.terminalId];
598+ const terminalGroups = normalizedThread.terminalGroups
599+ .map((group) => ({
600+ ...group,
601+ terminalIds: group.terminalIds.filter((id) => id !== action.terminalId),
602+ }))
603+ .filter((group) => group.terminalIds.length > 0);
604+ const nextGroupId = fallbackGroupId(action.terminalId);
605+ terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] });
606+
476607 return normalizeThreadTerminals({
477− ...thread,
608+ ...normalizedThread,
478609 terminalIds,
479610 activeTerminalId: action.terminalId,
480− terminalLayout: "tabs",
481− splitTerminalIds: [],
611+ activeTerminalGroupId: nextGroupId,
612+ terminalGroups,
482613 });
483614 }),
484615 };
@@ -487,12 +618,23 @@ export function reducer(state: AppState, action: Action): AppState { · 2 unmodified lines
487618 return {
488619 ...state,
489620 threads: updateThread(state.threads, action.threadId, (thread) => {
490− if (!thread.terminalIds.includes(action.terminalId)) {
621+ const normalizedThread = normalizeThreadTerminals(thread);
622+ if (!normalizedThread.terminalIds.includes(action.terminalId)) {
491623 return thread;
492624 }
625+ const nextActiveGroupIndex = findGroupIndexByTerminalId(
626+ normalizedThread.terminalGroups,
627+ action.terminalId,
628+ );
629+ const activeTerminalGroupId =
630+ nextActiveGroupIndex >= 0
631+ ? (normalizedThread.terminalGroups[nextActiveGroupIndex]?.id ??
632+ normalizedThread.activeTerminalGroupId)
633+ : normalizedThread.activeTerminalGroupId;
493634 return normalizeThreadTerminals({
494− ...thread,
635+ ...normalizedThread,
495636 activeTerminalId: action.terminalId,
637+ activeTerminalGroupId,
496638 });
497639 }),
498640 };
apps/web/src/types.ts+7−3
@@ -5,7 +5,11 @@ export type RuntimeMode = "approval-required" | "full-access"; · 4 unmodified lines
55 export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
66 export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
77 export const DEFAULT_THREAD_TERMINAL_ID = "default";
8−export type ThreadTerminalLayout = "single" | "split" | "tabs";
8+
9+export interface ThreadTerminalGroup {
10+ id: string;
11+ terminalIds: string[];
12+}
913
1014 export interface ChatImageAttachment {
1115 type: "image";
@@ -45,8 +49,8 @@ export interface Thread { · 33 unmodified lines
4549 terminalHeight: number;
4650 terminalIds: string[];
4751 activeTerminalId: string;
48− terminalLayout: ThreadTerminalLayout;
49− splitTerminalIds: string[];
52+ terminalGroups: ThreadTerminalGroup[];
53+ activeTerminalGroupId: string;
5054 session: ProviderSession | null;
5155 messages: ChatMessage[];
5256 events: ProviderEvent[];