authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 21:06:03-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-12 21:24:49-08:00
log07210d0c912398f405a318cda7ea627126616c82
treede6908183e32a1aee43d3f70fc0e84c9b4191e67
parentee463f6b76a9217fa1d5eb03519c039bed3db1cb

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

9 files changed, 783 insertions(+), 276 deletions(-)

apps/web/src/App.tsx+7-2
......@@ -83,8 +83,13 @@ function AutoProjectBootstrap() {
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() {
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() {
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 @@
11import { FitAddon } from "@xterm/addon-fit";
2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2 } from "lucide-react";
23import { type NativeApi } from "@t3tools/contracts";
34import { Terminal, type ITheme } from "@xterm/xterm";
45import {
......@@ -19,7 +20,7 @@ import { isTerminalClearShortcut } from "../terminal-shortcuts";
1920import {
2021 DEFAULT_THREAD_TERMINAL_HEIGHT,
2122 DEFAULT_THREAD_TERMINAL_ID,
22 type ThreadTerminalLayout,
23 type ThreadTerminalGroup,
2324} from "../types";
2425
2526const MIN_DRAWER_HEIGHT = 180;
......@@ -415,8 +416,8 @@ interface ThreadTerminalDrawerProps {
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({
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({
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({
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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
208270describe("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", () => {
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", () => {
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", () => {
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 {
88 type Project,
99 type RuntimeMode,
1010 type Thread,
11 type ThreadTerminalGroup,
1112} from "./types";
1213
1314const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
......@@ -39,6 +40,11 @@ const persistedMessageSchema = z.object({
3940 streaming: z.boolean(),
4041});
4142
43const persistedTerminalGroupSchema = z.object({
44 id: z.string().trim().min(1),
45 terminalIds: z.array(z.string().trim().min(1)),
46});
47
4248const 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({
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({
91100 version: z.literal(6).optional(),
92101});
93102
103export const persistedStateV7Schema = persistedStateBodySchema.extend({
104 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
105 version: z.literal(7).optional(),
106});
107
94108export const persistedStateV5Schema = persistedStateBodySchema.extend({
95109 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
96110 version: z.literal(5).optional(),
97111});
98112
99113const persistedStateSchema = z.union([
114 persistedStateV7Schema,
100115 persistedStateV6Schema,
101116 persistedStateV5Schema,
102117 persistedStateV4Schema,
......@@ -134,33 +149,91 @@ function hydrateThread(
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(
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(
235308
236309export 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(
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 {
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", () => {
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", () => {
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", () => {
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", () => {
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", () => {
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 {
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 {
7273 diffOpen: boolean;
7374}
7475
75const PERSISTED_STATE_KEY = "t3code:renderer-state:v6";
76const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";
7677const 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(
139141}
140142
141143function 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[] {
149149 return [DEFAULT_THREAD_TERMINAL_ID];
150150}
151151
152function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
153 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
154}
155
156function fallbackGroupId(terminalId: string): string {
157 return `group-${terminalId}`;
158}
159
160function 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
174function 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
223function findGroupIndexByTerminalId(
224 terminalGroups: ThreadTerminalGroup[],
225 terminalId: string,
226): number {
227 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
228}
229
152230function 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 {
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 {
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 {
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 {
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";
55export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
66export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
77export const DEFAULT_THREAD_TERMINAL_ID = "default";
8export type ThreadTerminalLayout = "single" | "split" | "tabs";
8
9export interface ThreadTerminalGroup {
10 id: string;
11 terminalIds: string[];
12}
913
1014export interface ChatImageAttachment {
1115 type: "image";
......@@ -45,8 +49,8 @@ export interface Thread {
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[];