authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-12 11:41:13-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-12 11:50:35-07:00
logc52ad29b31e890164d0e22443f36c1e39483d3bc
tree7338d975f87295e5297953304c179af735eb26b1
parentfcbf3f3c225ab41c44af3759ae27d8520ced61a4

Fix mod+N new thread flow and terminal split limits

- ensure `chat.new` creates a fresh draft after a promoted draft thread - enforce terminal cap per split group (4) while allowing additional terminal groups - refine sidebar row selected/active styling via shared class-name logic and tests

10 files changed, 306 insertions(+), 73 deletions(-)

apps/web/src/components/ChatView.browser.tsx+125
......@@ -20,6 +20,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
2020import { render } from "vitest-browser-react";
2121
2222import { useComposerDraftStore } from "../composerDraftStore";
23import { isMacPlatform } from "../lib/utils";
2324import { getRouter } from "../router";
2425import { useStore } from "../store";
2526import { estimateTimelineMessageHeight } from "./timelineHeight";
......@@ -1062,6 +1063,130 @@ describe("ChatView timeline estimator parity (full app)", () => {
10621063 }
10631064 });
10641065
1066 it("creates a new thread from the global chat.new shortcut", async () => {
1067 const mounted = await mountChatView({
1068 viewport: DEFAULT_VIEWPORT,
1069 snapshot: createSnapshotForTargetUser({
1070 targetMessageId: "msg-user-chat-shortcut-test" as MessageId,
1071 targetText: "chat shortcut test",
1072 }),
1073 configureFixture: (nextFixture) => {
1074 nextFixture.serverConfig = {
1075 ...nextFixture.serverConfig,
1076 keybindings: [
1077 {
1078 command: "chat.new",
1079 shortcut: {
1080 key: "o",
1081 metaKey: false,
1082 ctrlKey: false,
1083 shiftKey: true,
1084 altKey: false,
1085 modKey: true,
1086 },
1087 whenAst: {
1088 type: "not",
1089 node: { type: "identifier", name: "terminalFocus" },
1090 },
1091 },
1092 ],
1093 };
1094 },
1095 });
1096
1097 try {
1098 const useMetaForMod = isMacPlatform(navigator.platform);
1099 window.dispatchEvent(
1100 new KeyboardEvent("keydown", {
1101 key: "o",
1102 shiftKey: true,
1103 metaKey: useMetaForMod,
1104 ctrlKey: !useMetaForMod,
1105 bubbles: true,
1106 cancelable: true,
1107 }),
1108 );
1109
1110 await waitForURL(
1111 mounted.router,
1112 (path) => UUID_ROUTE_RE.test(path),
1113 "Route should have changed to a new draft thread UUID from the shortcut.",
1114 );
1115 } finally {
1116 await mounted.cleanup();
1117 }
1118 });
1119
1120 it("creates a fresh draft after the previous draft thread is promoted", async () => {
1121 const mounted = await mountChatView({
1122 viewport: DEFAULT_VIEWPORT,
1123 snapshot: createSnapshotForTargetUser({
1124 targetMessageId: "msg-user-promoted-draft-shortcut-test" as MessageId,
1125 targetText: "promoted draft shortcut test",
1126 }),
1127 configureFixture: (nextFixture) => {
1128 nextFixture.serverConfig = {
1129 ...nextFixture.serverConfig,
1130 keybindings: [
1131 {
1132 command: "chat.new",
1133 shortcut: {
1134 key: "o",
1135 metaKey: false,
1136 ctrlKey: false,
1137 shiftKey: true,
1138 altKey: false,
1139 modKey: true,
1140 },
1141 whenAst: {
1142 type: "not",
1143 node: { type: "identifier", name: "terminalFocus" },
1144 },
1145 },
1146 ],
1147 };
1148 },
1149 });
1150
1151 try {
1152 const newThreadButton = page.getByTestId("new-thread-button");
1153 await expect.element(newThreadButton).toBeInTheDocument();
1154 await newThreadButton.click();
1155
1156 const promotedThreadPath = await waitForURL(
1157 mounted.router,
1158 (path) => UUID_ROUTE_RE.test(path),
1159 "Route should have changed to a promoted draft thread UUID.",
1160 );
1161 const promotedThreadId = promotedThreadPath.slice(1) as ThreadId;
1162
1163 const { syncServerReadModel } = useStore.getState();
1164 syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId));
1165 useComposerDraftStore.getState().clearDraftThread(promotedThreadId);
1166
1167 const useMetaForMod = isMacPlatform(navigator.platform);
1168 window.dispatchEvent(
1169 new KeyboardEvent("keydown", {
1170 key: "o",
1171 shiftKey: true,
1172 metaKey: useMetaForMod,
1173 ctrlKey: !useMetaForMod,
1174 bubbles: true,
1175 cancelable: true,
1176 }),
1177 );
1178
1179 const freshThreadPath = await waitForURL(
1180 mounted.router,
1181 (path) => UUID_ROUTE_RE.test(path) && path !== promotedThreadPath,
1182 "Shortcut should create a fresh draft instead of reusing the promoted thread.",
1183 );
1184 expect(freshThreadPath).not.toBe(promotedThreadPath);
1185 } finally {
1186 await mounted.cleanup();
1187 }
1188 });
1189
10651190 it("keeps long proposed plans lightweight until the user expands them", async () => {
10661191 const mounted = await mountChatView({
10671192 viewport: DEFAULT_VIEWPORT,
apps/web/src/components/ChatView.tsx+16-8
......@@ -76,7 +76,7 @@ import {
7676 DEFAULT_INTERACTION_MODE,
7777 DEFAULT_RUNTIME_MODE,
7878 DEFAULT_THREAD_TERMINAL_ID,
79 MAX_THREAD_TERMINAL_COUNT,
79 MAX_TERMINALS_PER_GROUP,
8080 type ChatMessage,
8181 type TurnDiffSummary,
8282} from "../types";
......@@ -1037,7 +1037,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
10371037 (activeThread.messages.length > 0 ||
10381038 (activeThread.session !== null && activeThread.session.status !== "closed")),
10391039 );
1040 const hasReachedTerminalLimit = terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
1040 const activeTerminalGroup =
1041 terminalState.terminalGroups.find(
1042 (group) => group.id === terminalState.activeTerminalGroupId,
1043 ) ??
1044 terminalState.terminalGroups.find((group) =>
1045 group.terminalIds.includes(terminalState.activeTerminalId),
1046 ) ??
1047 null;
1048 const hasReachedSplitLimit =
1049 (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP;
10411050 const setThreadError = useCallback(
10421051 (targetThreadId: ThreadId | null, error: string | null) => {
10431052 if (!targetThreadId) return;
......@@ -1085,17 +1094,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
10851094 setTerminalOpen(!terminalState.terminalOpen);
10861095 }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]);
10871096 const splitTerminal = useCallback(() => {
1088 if (!activeThreadId || hasReachedTerminalLimit) return;
1097 if (!activeThreadId || hasReachedSplitLimit) return;
10891098 const terminalId = `terminal-${randomUUID()}`;
10901099 storeSplitTerminal(activeThreadId, terminalId);
10911100 setTerminalFocusRequestId((value) => value + 1);
1092 }, [activeThreadId, storeSplitTerminal, hasReachedTerminalLimit]);
1101 }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]);
10931102 const createNewTerminal = useCallback(() => {
1094 if (!activeThreadId || hasReachedTerminalLimit) return;
1103 if (!activeThreadId) return;
10951104 const terminalId = `terminal-${randomUUID()}`;
10961105 storeNewTerminal(activeThreadId, terminalId);
10971106 setTerminalFocusRequestId((value) => value + 1);
1098 }, [activeThreadId, storeNewTerminal, hasReachedTerminalLimit]);
1107 }, [activeThreadId, storeNewTerminal]);
10991108 const activateTerminal = useCallback(
11001109 (terminalId: string) => {
11011110 if (!activeThreadId) return;
......@@ -1162,8 +1171,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
11621171 DEFAULT_THREAD_TERMINAL_ID;
11631172 const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId);
11641173 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
1165 const shouldCreateNewTerminal =
1166 wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT;
1174 const shouldCreateNewTerminal = wantsNewTerminal;
11671175 const targetTerminalId = shouldCreateNewTerminal
11681176 ? `terminal-${randomUUID()}`
11691177 : baseTerminalId;
apps/web/src/components/Sidebar.logic.test.ts+25
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
33import {
44 hasUnseenCompletion,
5 resolveThreadRowClassName,
56 resolveThreadStatusPill,
67 shouldClearThreadSelectionOnMouseDown,
78} from "./Sidebar.logic";
......@@ -154,3 +155,27 @@ describe("resolveThreadStatusPill", () => {
154155 ).toMatchObject({ label: "Completed", pulse: false });
155156 });
156157});
158
159describe("resolveThreadRowClassName", () => {
160 it("uses the darker selected palette when a thread is both selected and active", () => {
161 const className = resolveThreadRowClassName({ isActive: true, isSelected: true });
162 expect(className).toContain("bg-primary/22");
163 expect(className).toContain("hover:bg-primary/26");
164 expect(className).toContain("dark:bg-primary/30");
165 expect(className).not.toContain("bg-accent/85");
166 });
167
168 it("uses selected hover colors for selected threads", () => {
169 const className = resolveThreadRowClassName({ isActive: false, isSelected: true });
170 expect(className).toContain("bg-primary/15");
171 expect(className).toContain("hover:bg-primary/19");
172 expect(className).toContain("dark:bg-primary/22");
173 expect(className).not.toContain("hover:bg-accent");
174 });
175
176 it("keeps the accent palette for active-only threads", () => {
177 const className = resolveThreadRowClassName({ isActive: true, isSelected: false });
178 expect(className).toContain("bg-accent/85");
179 expect(className).toContain("hover:bg-accent");
180 });
181});
apps/web/src/components/Sidebar.logic.ts+32
......@@ -1,4 +1,5 @@
11import type { Thread } from "../types";
2import { cn } from "../lib/utils";
23import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic";
34
45export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
......@@ -37,6 +38,37 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null
3738 return !target.closest(THREAD_SELECTION_SAFE_SELECTOR);
3839}
3940
41export function resolveThreadRowClassName(input: {
42 isActive: boolean;
43 isSelected: boolean;
44}): string {
45 const baseClassName =
46 "h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none focus-visible:ring-0";
47
48 if (input.isSelected && input.isActive) {
49 return cn(
50 baseClassName,
51 "bg-primary/22 text-foreground font-medium hover:bg-primary/26 hover:text-foreground dark:bg-primary/30 dark:hover:bg-primary/36",
52 );
53 }
54
55 if (input.isSelected) {
56 return cn(
57 baseClassName,
58 "bg-primary/15 text-foreground hover:bg-primary/19 hover:text-foreground dark:bg-primary/22 dark:hover:bg-primary/28",
59 );
60 }
61
62 if (input.isActive) {
63 return cn(
64 baseClassName,
65 "bg-accent/85 text-foreground font-medium hover:bg-accent hover:text-foreground dark:bg-accent/55 dark:hover:bg-accent/70",
66 );
67 }
68
69 return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground");
70}
71
4072export function resolveThreadStatusPill(input: {
4173 thread: ThreadStatusInput;
4274 hasPendingApprovals: boolean;
apps/web/src/components/Sidebar.tsx+10-9
......@@ -83,7 +83,11 @@ import {
8383import { useThreadSelectionStore } from "../threadSelectionStore";
8484import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
8585import { isNonEmpty as isNonEmptyString } from "effect/String";
86import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic";
86import {
87 resolveThreadRowClassName,
88 resolveThreadStatusPill,
89 shouldClearThreadSelectionOnMouseDown,
90} from "./Sidebar.logic";
8791
8892const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
8993const THREAD_PREVIEW_LIMIT = 6;
......@@ -1397,13 +1401,10 @@ export default function Sidebar() {
13971401 render={<div role="button" tabIndex={0} />}
13981402 size="sm"
13991403 isActive={isActive}
1400 className={`h-7 w-full translate-x-0 cursor-default justify-start px-2 text-left select-none hover:bg-accent hover:text-foreground focus-visible:ring-0 ${
1401 isSelected
1402 ? "bg-primary/15 text-foreground dark:bg-primary/10"
1403 : isActive
1404 ? "bg-accent/85 text-foreground font-medium dark:bg-accent/55"
1405 : "text-muted-foreground"
1406 }`}
1404 className={resolveThreadRowClassName({
1405 isActive,
1406 isSelected,
1407 })}
14071408 onClick={(event) => {
14081409 handleThreadClick(
14091410 event,
......@@ -1541,7 +1542,7 @@ export default function Sidebar() {
15411542 <span
15421543 className={`text-[10px] ${
15431544 isHighlighted
1544 ? "text-foreground/65"
1545 ? "text-foreground/72 dark:text-foreground/82"
15451546 : "text-muted-foreground/40"
15461547 }`}
15471548 >
apps/web/src/components/ThreadTerminalDrawer.tsx+14-25
......@@ -22,7 +22,7 @@ import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keyb
2222import {
2323 DEFAULT_THREAD_TERMINAL_HEIGHT,
2424 DEFAULT_THREAD_TERMINAL_ID,
25 MAX_THREAD_TERMINAL_COUNT,
25 MAX_TERMINALS_PER_GROUP,
2626 type ThreadTerminalGroup,
2727} from "../types";
2828import { readNativeApi } from "~/nativeApi";
......@@ -605,7 +605,7 @@ export default function ThreadTerminalDrawer({
605605 const showGroupHeaders =
606606 resolvedTerminalGroups.length > 1 ||
607607 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
608 const hasReachedTerminalLimit = normalizedTerminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
608 const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP;
609609 const terminalLabelById = useMemo(
610610 () =>
611611 new Map(
......@@ -613,27 +613,24 @@ export default function ThreadTerminalDrawer({
613613 ),
614614 [normalizedTerminalIds],
615615 );
616 const splitTerminalActionLabel = hasReachedTerminalLimit
617 ? `Split Terminal (max ${MAX_THREAD_TERMINAL_COUNT})`
616 const splitTerminalActionLabel = hasReachedSplitLimit
617 ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)`
618618 : splitShortcutLabel
619619 ? `Split Terminal (${splitShortcutLabel})`
620620 : "Split Terminal";
621 const newTerminalActionLabel = hasReachedTerminalLimit
622 ? `New Terminal (max ${MAX_THREAD_TERMINAL_COUNT})`
623 : newShortcutLabel
624 ? `New Terminal (${newShortcutLabel})`
625 : "New Terminal";
621 const newTerminalActionLabel = newShortcutLabel
622 ? `New Terminal (${newShortcutLabel})`
623 : "New Terminal";
626624 const closeTerminalActionLabel = closeShortcutLabel
627625 ? `Close Terminal (${closeShortcutLabel})`
628626 : "Close Terminal";
629627 const onSplitTerminalAction = useCallback(() => {
630 if (hasReachedTerminalLimit) return;
628 if (hasReachedSplitLimit) return;
631629 onSplitTerminal();
632 }, [hasReachedTerminalLimit, onSplitTerminal]);
630 }, [hasReachedSplitLimit, onSplitTerminal]);
633631 const onNewTerminalAction = useCallback(() => {
634 if (hasReachedTerminalLimit) return;
635632 onNewTerminal();
636 }, [hasReachedTerminalLimit, onNewTerminal]);
633 }, [onNewTerminal]);
637634
638635 useEffect(() => {
639636 onHeightChangeRef.current = onHeightChange;
......@@ -744,7 +741,7 @@ export default function ThreadTerminalDrawer({
744741 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70">
745742 <TerminalActionButton
746743 className={`p-1 text-foreground/90 transition-colors ${
747 hasReachedTerminalLimit
744 hasReachedSplitLimit
748745 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
749746 : "hover:bg-accent"
750747 }`}
......@@ -755,11 +752,7 @@ export default function ThreadTerminalDrawer({
755752 </TerminalActionButton>
756753 <div className="h-4 w-px bg-border/80" />
757754 <TerminalActionButton
758 className={`p-1 text-foreground/90 transition-colors ${
759 hasReachedTerminalLimit
760 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
761 : "hover:bg-accent"
762 }`}
755 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
763756 onClick={onNewTerminalAction}
764757 label={newTerminalActionLabel}
765758 >
......@@ -839,7 +832,7 @@ export default function ThreadTerminalDrawer({
839832 <div className="inline-flex h-full items-stretch">
840833 <TerminalActionButton
841834 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
842 hasReachedTerminalLimit
835 hasReachedSplitLimit
843836 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
844837 : "hover:bg-accent/70"
845838 }`}
......@@ -849,11 +842,7 @@ export default function ThreadTerminalDrawer({
849842 <SquareSplitHorizontal className="size-3.25" />
850843 </TerminalActionButton>
851844 <TerminalActionButton
852 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
853 hasReachedTerminalLimit
854 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
855 : "hover:bg-accent/70"
856 }`}
845 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
857846 onClick={onNewTerminalAction}
858847 label={newTerminalActionLabel}
859848 >
apps/web/src/hooks/useHandleNewThread.ts+24-21
......@@ -1,32 +1,29 @@
11import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";
22import { useNavigate, useParams } from "@tanstack/react-router";
33import { useCallback } from "react";
4import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
4import {
5 type DraftThreadEnvMode,
6 type DraftThreadState,
7 useComposerDraftStore,
8} from "../composerDraftStore";
59import { newThreadId } from "../lib/utils";
610import { useStore } from "../store";
711
812export function useHandleNewThread() {
913 const projects = useStore((store) => store.projects);
1014 const threads = useStore((store) => store.threads);
11 const getDraftThreadByProjectId = useComposerDraftStore(
12 (store) => store.getDraftThreadByProjectId,
13 );
14 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
15 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
16 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
17 const clearProjectDraftThreadId = useComposerDraftStore(
18 (store) => store.clearProjectDraftThreadId,
19 );
2015 const navigate = useNavigate();
2116 const routeThreadId = useParams({
2217 strict: false,
2318 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
2419 });
20 const activeDraftThread = useComposerDraftStore((store) =>
21 routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null,
22 );
2523
2624 const activeThread = routeThreadId
2725 ? threads.find((thread) => thread.id === routeThreadId)
2826 : undefined;
29 const activeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null;
3027
3128 const handleNewThread = useCallback(
3229 (
......@@ -37,10 +34,20 @@ export function useHandleNewThread() {
3734 envMode?: DraftThreadEnvMode;
3835 },
3936 ): Promise<void> => {
37 const {
38 clearProjectDraftThreadId,
39 getDraftThread,
40 getDraftThreadByProjectId,
41 setDraftThreadContext,
42 setProjectDraftThreadId,
43 } = useComposerDraftStore.getState();
4044 const hasBranchOption = options?.branch !== undefined;
4145 const hasWorktreePathOption = options?.worktreePath !== undefined;
4246 const hasEnvModeOption = options?.envMode !== undefined;
4347 const storedDraftThread = getDraftThreadByProjectId(projectId);
48 const latestActiveDraftThread: DraftThreadState | null = routeThreadId
49 ? getDraftThread(routeThreadId)
50 : null;
4451 if (storedDraftThread) {
4552 return (async () => {
4653 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
......@@ -63,7 +70,11 @@ export function useHandleNewThread() {
6370
6471 clearProjectDraftThreadId(projectId);
6572
66 if (activeDraftThread && routeThreadId && activeDraftThread.projectId === projectId) {
73 if (
74 latestActiveDraftThread &&
75 routeThreadId &&
76 latestActiveDraftThread.projectId === projectId
77 ) {
6778 if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) {
6879 setDraftThreadContext(routeThreadId, {
6980 ...(hasBranchOption ? { branch: options?.branch ?? null } : {}),
......@@ -92,15 +103,7 @@ export function useHandleNewThread() {
92103 });
93104 })();
94105 },
95 [
96 activeDraftThread,
97 clearProjectDraftThreadId,
98 getDraftThreadByProjectId,
99 navigate,
100 routeThreadId,
101 setDraftThreadContext,
102 setProjectDraftThreadId,
103 ],
106 [navigate, routeThreadId],
104107 );
105108
106109 return {
apps/web/src/terminalStateStore.test.ts+49
......@@ -46,6 +46,28 @@ describe("terminalStateStore actions", () => {
4646 ]);
4747 });
4848
49 it("caps splits at four terminals per group", () => {
50 const store = useTerminalStateStore.getState();
51 store.splitTerminal(THREAD_ID, "terminal-2");
52 store.splitTerminal(THREAD_ID, "terminal-3");
53 store.splitTerminal(THREAD_ID, "terminal-4");
54 store.splitTerminal(THREAD_ID, "terminal-5");
55
56 const terminalState = selectThreadTerminalState(
57 useTerminalStateStore.getState().terminalStateByThreadId,
58 THREAD_ID,
59 );
60 expect(terminalState.terminalIds).toEqual([
61 "default",
62 "terminal-2",
63 "terminal-3",
64 "terminal-4",
65 ]);
66 expect(terminalState.terminalGroups).toEqual([
67 { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] },
68 ]);
69 });
70
4971 it("creates new terminals in a separate group", () => {
5072 useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2");
5173
......@@ -62,6 +84,33 @@ describe("terminalStateStore actions", () => {
6284 ]);
6385 });
6486
87 it("allows unlimited groups while keeping each group capped at four terminals", () => {
88 const store = useTerminalStateStore.getState();
89 store.splitTerminal(THREAD_ID, "terminal-2");
90 store.splitTerminal(THREAD_ID, "terminal-3");
91 store.splitTerminal(THREAD_ID, "terminal-4");
92 store.newTerminal(THREAD_ID, "terminal-5");
93 store.newTerminal(THREAD_ID, "terminal-6");
94
95 const terminalState = selectThreadTerminalState(
96 useTerminalStateStore.getState().terminalStateByThreadId,
97 THREAD_ID,
98 );
99 expect(terminalState.terminalIds).toEqual([
100 "default",
101 "terminal-2",
102 "terminal-3",
103 "terminal-4",
104 "terminal-5",
105 "terminal-6",
106 ]);
107 expect(terminalState.terminalGroups).toEqual([
108 { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] },
109 { id: "group-terminal-5", terminalIds: ["terminal-5"] },
110 { id: "group-terminal-6", terminalIds: ["terminal-6"] },
111 ]);
112 });
113
65114 it("tracks and clears terminal subprocess activity", () => {
66115 const store = useTerminalStateStore.getState();
67116 store.splitTerminal(THREAD_ID, "terminal-2");
apps/web/src/terminalStateStore.ts+10-9
......@@ -11,7 +11,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
1111import {
1212 DEFAULT_THREAD_TERMINAL_HEIGHT,
1313 DEFAULT_THREAD_TERMINAL_ID,
14 MAX_THREAD_TERMINAL_COUNT,
14 MAX_TERMINALS_PER_GROUP,
1515 type ThreadTerminalGroup,
1616} from "./types";
1717
......@@ -28,10 +28,7 @@ interface ThreadTerminalState {
2828const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
2929
3030function normalizeTerminalIds(terminalIds: string[]): string[] {
31 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice(
32 0,
33 MAX_THREAD_TERMINAL_COUNT,
34 );
31 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
3532 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
3633}
3734
......@@ -243,10 +240,6 @@ function upsertTerminalIntoGroups(
243240 }
244241
245242 const isNewTerminal = !normalized.terminalIds.includes(terminalId);
246 if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
247 return normalized;
248 }
249
250243 const terminalIds = isNewTerminal
251244 ? [...normalized.terminalIds, terminalId]
252245 : normalized.terminalIds;
......@@ -297,6 +290,14 @@ function upsertTerminalIntoGroups(
297290 return normalized;
298291 }
299292
293 if (
294 isNewTerminal &&
295 !destinationGroup.terminalIds.includes(terminalId) &&
296 destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP
297 ) {
298 return normalized;
299 }
300
300301 if (!destinationGroup.terminalIds.includes(terminalId)) {
301302 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);
302303 if (anchorIndex >= 0) {
apps/web/src/types.ts+1-1
......@@ -20,7 +20,7 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
2020export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default";
2121export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
2222export const DEFAULT_THREAD_TERMINAL_ID = "default";
23export const MAX_THREAD_TERMINAL_COUNT = 4;
23export const MAX_TERMINALS_PER_GROUP = 4;
2424export type ProjectScript = ContractProjectScript;
2525
2626export interface ThreadTerminalGroup {