authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 12:10:06-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-17 16:05:59-08:00
log376efdd3401235dbba95474689e7c092aa08b858
tree104d3f5f38c7d184e2e8846fd624cf6f4b1d1fb6
parent7a5dd62d2f799bd41982eeae04eb24a97c0a7430

Cap thread terminals at four and disable add/split controls

- enforce max 4 terminals per thread in reducer and terminal normalization - block split/new terminal actions in chat and drawer when limit is reached - add reducer tests for split/new terminal cap behavior

5 files changed, 146 insertions(+), 29 deletions(-)

apps/web/src/components/ChatView.tsx+7-5
...@@ -51,7 +51,7 @@ import {...@@ -51,7 +51,7 @@ import {
51} from "../session-logic";51} from "../session-logic";
52import { isScrollContainerNearBottom } from "../chat-scroll";52import { isScrollContainerNearBottom } from "../chat-scroll";
53import { useStore } from "../store";53import { useStore } from "../store";
54import type { ChatImageAttachment } from "../types";54import { MAX_THREAD_TERMINAL_COUNT, type ChatImageAttachment } from "../types";
55import BranchToolbar from "./BranchToolbar";55import BranchToolbar from "./BranchToolbar";
56import GitActionsControl from "./GitActionsControl";56import GitActionsControl from "./GitActionsControl";
57import {57import {
...@@ -294,6 +294,8 @@ export default function ChatView() {...@@ -294,6 +294,8 @@ export default function ChatView() {
294 (activeThread.messages.length > 0 ||294 (activeThread.messages.length > 0 ||
295 (activeThread.session !== null && activeThread.session.status !== "closed")),295 (activeThread.session !== null && activeThread.session.status !== "closed")),
296 );296 );
297 const hasReachedTerminalLimit =
298 (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;
297299
298 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {300 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
299 for (const image of images) {301 for (const image of images) {
...@@ -322,23 +324,23 @@ export default function ChatView() {...@@ -322,23 +324,23 @@ export default function ChatView() {
322 });324 });
323 }, [activeThread?.terminalOpen, activeThreadId, dispatch]);325 }, [activeThread?.terminalOpen, activeThreadId, dispatch]);
324 const splitTerminal = useCallback(() => {326 const splitTerminal = useCallback(() => {
325 if (!activeThreadId) return;327 if (!activeThreadId || hasReachedTerminalLimit) return;
326 dispatch({328 dispatch({
327 type: "SPLIT_THREAD_TERMINAL",329 type: "SPLIT_THREAD_TERMINAL",
328 threadId: activeThreadId,330 threadId: activeThreadId,
329 terminalId: `terminal-${crypto.randomUUID()}`,331 terminalId: `terminal-${crypto.randomUUID()}`,
330 });332 });
331 setTerminalFocusRequestId((value) => value + 1);333 setTerminalFocusRequestId((value) => value + 1);
332 }, [activeThreadId, dispatch]);334 }, [activeThreadId, dispatch, hasReachedTerminalLimit]);
333 const createNewTerminal = useCallback(() => {335 const createNewTerminal = useCallback(() => {
334 if (!activeThreadId) return;336 if (!activeThreadId || hasReachedTerminalLimit) return;
335 dispatch({337 dispatch({
336 type: "NEW_THREAD_TERMINAL",338 type: "NEW_THREAD_TERMINAL",
337 threadId: activeThreadId,339 threadId: activeThreadId,
338 terminalId: `terminal-${crypto.randomUUID()}`,340 terminalId: `terminal-${crypto.randomUUID()}`,
339 });341 });
340 setTerminalFocusRequestId((value) => value + 1);342 setTerminalFocusRequestId((value) => value + 1);
341 }, [activeThreadId, dispatch]);343 }, [activeThreadId, dispatch, hasReachedTerminalLimit]);
342 const activateTerminal = useCallback(344 const activateTerminal = useCallback(
343 (terminalId: string) => {345 (terminalId: string) => {
344 if (!activeThreadId) return;346 if (!activeThreadId) return;
apps/web/src/components/ThreadTerminalDrawer.tsx+47-21
...@@ -22,6 +22,7 @@ import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keyb...@@ -22,6 +22,7 @@ import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keyb
22import {22import {
23 DEFAULT_THREAD_TERMINAL_HEIGHT,23 DEFAULT_THREAD_TERMINAL_HEIGHT,
24 DEFAULT_THREAD_TERMINAL_ID,24 DEFAULT_THREAD_TERMINAL_ID,
25 MAX_THREAD_TERMINAL_COUNT,
25 type ThreadTerminalGroup,26 type ThreadTerminalGroup,
26} from "../types";27} from "../types";
2728
...@@ -581,6 +582,7 @@ export default function ThreadTerminalDrawer({...@@ -581,6 +582,7 @@ export default function ThreadTerminalDrawer({
581 const showGroupHeaders =582 const showGroupHeaders =
582 resolvedTerminalGroups.length > 1 ||583 resolvedTerminalGroups.length > 1 ||
583 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);584 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
585 const hasReachedTerminalLimit = normalizedTerminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
584 const terminalLabelById = useMemo(586 const terminalLabelById = useMemo(
585 () =>587 () =>
586 new Map(588 new Map(
...@@ -588,9 +590,23 @@ export default function ThreadTerminalDrawer({...@@ -588,9 +590,23 @@ export default function ThreadTerminalDrawer({
588 ),590 ),
589 [normalizedTerminalIds],591 [normalizedTerminalIds],
590 );592 );
593 const splitTerminalActionLabel = hasReachedTerminalLimit
594 ? `Split Terminal (max ${MAX_THREAD_TERMINAL_COUNT})`
595 : (splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal");
596 const newTerminalActionLabel = hasReachedTerminalLimit
597 ? `New Terminal (max ${MAX_THREAD_TERMINAL_COUNT})`
598 : (newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal");
591 const closeTerminalActionLabel = closeShortcutLabel599 const closeTerminalActionLabel = closeShortcutLabel
592 ? `Close Terminal (${closeShortcutLabel})`600 ? `Close Terminal (${closeShortcutLabel})`
593 : "Close Terminal";601 : "Close Terminal";
602 const onSplitTerminalAction = useCallback(() => {
603 if (hasReachedTerminalLimit) return;
604 onSplitTerminal();
605 }, [hasReachedTerminalLimit, onSplitTerminal]);
606 const onNewTerminalAction = useCallback(() => {
607 if (hasReachedTerminalLimit) return;
608 onNewTerminal();
609 }, [hasReachedTerminalLimit, onNewTerminal]);
594610
595 useEffect(() => {611 useEffect(() => {
596 onHeightChangeRef.current = onHeightChange;612 onHeightChangeRef.current = onHeightChange;
...@@ -685,7 +701,7 @@ export default function ThreadTerminalDrawer({...@@ -685,7 +701,7 @@ export default function ThreadTerminalDrawer({
685701
686 return (702 return (
687 <aside703 <aside
688 className="thread-terminal-drawer relative flex shrink-0 flex-col border-t border-border/80 bg-background"704 className="thread-terminal-drawer relative flex min-w-0 shrink-0 flex-col overflow-hidden border-t border-border/80 bg-background"
689 style={{ height: `${drawerHeight}px` }}705 style={{ height: `${drawerHeight}px` }}
690 >706 >
691 <div707 <div
...@@ -700,19 +716,25 @@ export default function ThreadTerminalDrawer({...@@ -700,19 +716,25 @@ export default function ThreadTerminalDrawer({
700 <div className="pointer-events-none absolute right-2 top-2 z-20">716 <div className="pointer-events-none absolute right-2 top-2 z-20">
701 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70">717 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70">
702 <TerminalActionButton718 <TerminalActionButton
703 className="p-1 text-foreground/90 transition-colors hover:bg-accent"719 className={`p-1 text-foreground/90 transition-colors ${
704 onClick={onSplitTerminal}720 hasReachedTerminalLimit
705 label={721 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
706 splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"722 : "hover:bg-accent"
707 }723 }`}
724 onClick={onSplitTerminalAction}
725 label={splitTerminalActionLabel}
708 >726 >
709 <SquareSplitHorizontal className="size-3.25" />727 <SquareSplitHorizontal className="size-3.25" />
710 </TerminalActionButton>728 </TerminalActionButton>
711 <div className="h-4 w-px bg-border/80" />729 <div className="h-4 w-px bg-border/80" />
712 <TerminalActionButton730 <TerminalActionButton
713 className="p-1 text-foreground/90 transition-colors hover:bg-accent"731 className={`p-1 text-foreground/90 transition-colors ${
714 onClick={onNewTerminal}732 hasReachedTerminalLimit
715 label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}733 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
734 : "hover:bg-accent"
735 }`}
736 onClick={onNewTerminalAction}
737 label={newTerminalActionLabel}
716 >738 >
717 <Plus className="size-3.25" />739 <Plus className="size-3.25" />
718 </TerminalActionButton>740 </TerminalActionButton>
...@@ -733,9 +755,9 @@ export default function ThreadTerminalDrawer({...@@ -733,9 +755,9 @@ export default function ThreadTerminalDrawer({
733 <div className="min-w-0 flex-1">755 <div className="min-w-0 flex-1">
734 {isSplitView ? (756 {isSplitView ? (
735 <div757 <div
736 className="grid h-full w-full gap-0 overflow-x-auto"758 className="grid h-full w-full min-w-0 gap-0 overflow-hidden"
737 style={{759 style={{
738 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(260px, 1fr))`,760 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
739 }}761 }}
740 >762 >
741 {visibleTerminalIds.map((terminalId) => (763 {visibleTerminalIds.map((terminalId) => (
...@@ -787,20 +809,24 @@ export default function ThreadTerminalDrawer({...@@ -787,20 +809,24 @@ export default function ThreadTerminalDrawer({
787 <div className="flex h-[22px] items-stretch justify-end border-b border-border/70">809 <div className="flex h-[22px] items-stretch justify-end border-b border-border/70">
788 <div className="inline-flex h-full items-stretch">810 <div className="inline-flex h-full items-stretch">
789 <TerminalActionButton811 <TerminalActionButton
790 className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70"812 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
791 onClick={onSplitTerminal}813 hasReachedTerminalLimit
792 label={814 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
793 splitShortcutLabel815 : "hover:bg-accent/70"
794 ? `Split Terminal (${splitShortcutLabel})`816 }`}
795 : "Split Terminal"817 onClick={onSplitTerminalAction}
796 }818 label={splitTerminalActionLabel}
797 >819 >
798 <SquareSplitHorizontal className="size-3.25" />820 <SquareSplitHorizontal className="size-3.25" />
799 </TerminalActionButton>821 </TerminalActionButton>
800 <TerminalActionButton822 <TerminalActionButton
801 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"823 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
802 onClick={onNewTerminal}824 hasReachedTerminalLimit
803 label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}825 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
826 : "hover:bg-accent/70"
827 }`}
828 onClick={onNewTerminalAction}
829 label={newTerminalActionLabel}
804 >830 >
805 <Plus className="size-3.25" />831 <Plus className="size-3.25" />
806 </TerminalActionButton>832 </TerminalActionButton>
apps/web/src/store.test.ts+69-1
...@@ -2,7 +2,11 @@ import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/con...@@ -2,7 +2,11 @@ import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/con
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { type AppState, reducer } from "./store";4import { type AppState, reducer } from "./store";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";5import {
6 DEFAULT_THREAD_TERMINAL_HEIGHT,
7 DEFAULT_THREAD_TERMINAL_ID,
8 MAX_THREAD_TERMINAL_COUNT,
9} from "./types";
6import type { Thread } from "./types";10import type { Thread } from "./types";
711
8type TerminalStartedEvent = Extract<TerminalEvent, { type: "started" }>;12type TerminalStartedEvent = Extract<TerminalEvent, { type: "started" }>;
...@@ -255,6 +259,70 @@ describe("store reducer thread continuity", () => {...@@ -255,6 +259,70 @@ describe("store reducer thread continuity", () => {
255 expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);259 expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
256 });260 });
257261
262 it("caps split terminals at four per thread", () => {
263 const state = makeState(
264 makeThread({
265 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
266 activeTerminalId: "term-4",
267 terminalGroups: [
268 {
269 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
270 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
271 },
272 ],
273 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
274 }),
275 );
276 const next = reducer(state, {
277 type: "SPLIT_THREAD_TERMINAL",
278 threadId: "thread-local-1",
279 terminalId: "term-5",
280 });
281
282 expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT);
283 expect(next.threads[0]?.terminalIds).toEqual([
284 DEFAULT_THREAD_TERMINAL_ID,
285 "term-2",
286 "term-3",
287 "term-4",
288 ]);
289 expect(next.threads[0]?.activeTerminalId).toBe("term-4");
290 });
291
292 it("caps new terminals at four per thread", () => {
293 const state = makeState(
294 makeThread({
295 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"],
296 activeTerminalId: "term-4",
297 terminalGroups: [
298 {
299 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
300 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
301 },
302 { id: "group-term-2", terminalIds: ["term-2"] },
303 { id: "group-term-3", terminalIds: ["term-3"] },
304 { id: "group-term-4", terminalIds: ["term-4"] },
305 ],
306 activeTerminalGroupId: "group-term-4",
307 }),
308 );
309 const next = reducer(state, {
310 type: "NEW_THREAD_TERMINAL",
311 threadId: "thread-local-1",
312 terminalId: "term-5",
313 });
314
315 expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT);
316 expect(next.threads[0]?.terminalIds).toEqual([
317 DEFAULT_THREAD_TERMINAL_ID,
318 "term-2",
319 "term-3",
320 "term-4",
321 ]);
322 expect(next.threads[0]?.activeTerminalId).toBe("term-4");
323 expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-4");
324 });
325
258 it("closes a terminal and keeps grouped layout coherent", () => {326 it("closes a terminal and keeps grouped layout coherent", () => {
259 const state = makeState(327 const state = makeState(
260 makeThread({328 makeThread({
apps/web/src/store.ts+22-2
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 type ChatAttachment,16 type ChatAttachment,
17 DEFAULT_THREAD_TERMINAL_ID,17 DEFAULT_THREAD_TERMINAL_ID,
18 DEFAULT_RUNTIME_MODE,18 DEFAULT_RUNTIME_MODE,
19 MAX_THREAD_TERMINAL_COUNT,
19 type Project,20 type Project,
20 type RuntimeMode,21 type RuntimeMode,
21 type Thread,22 type Thread,
...@@ -110,7 +111,12 @@ function readPersistedState(): AppState {...@@ -110,7 +111,12 @@ function readPersistedState(): AppState {
110 const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1);111 const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1);
111 if (!hydrated) return initialState;112 if (!hydrated) return initialState;
112113
113 return { ...hydrated, diffOpen: false };114 const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread));
115 const activeThreadId = threads.some((thread) => thread.id === hydrated.activeThreadId)
116 ? hydrated.activeThreadId
117 : (threads[0]?.id ?? null);
118
119 return { ...hydrated, threads, activeThreadId, diffOpen: false };
114 } catch {120 } catch {
115 return initialState;121 return initialState;
116 }122 }
...@@ -139,7 +145,7 @@ function updateThread(...@@ -139,7 +145,7 @@ function updateThread(
139145
140function normalizeTerminalIds(terminalIds: string[]): string[] {146function normalizeTerminalIds(terminalIds: string[]): string[] {
141 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);147 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
142 const unique = [...new Set(ids)];148 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);
143 if (unique.length > 0) {149 if (unique.length > 0) {
144 return unique;150 return unique;
145 }151 }
...@@ -550,6 +556,13 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -550,6 +556,13 @@ export function reducer(state: AppState, action: Action): AppState {
550 ...state,556 ...state,
551 threads: updateThread(state.threads, action.threadId, (thread) => {557 threads: updateThread(state.threads, action.threadId, (thread) => {
552 const normalizedThread = normalizeThreadTerminals(thread);558 const normalizedThread = normalizeThreadTerminals(thread);
559 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
560 if (
561 isNewTerminal &&
562 normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT
563 ) {
564 return normalizedThread;
565 }
553 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)566 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
554 ? normalizedThread.terminalIds567 ? normalizedThread.terminalIds
555 : [...normalizedThread.terminalIds, action.terminalId];568 : [...normalizedThread.terminalIds, action.terminalId];
...@@ -616,6 +629,13 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -616,6 +629,13 @@ export function reducer(state: AppState, action: Action): AppState {
616 ...state,629 ...state,
617 threads: updateThread(state.threads, action.threadId, (thread) => {630 threads: updateThread(state.threads, action.threadId, (thread) => {
618 const normalizedThread = normalizeThreadTerminals(thread);631 const normalizedThread = normalizeThreadTerminals(thread);
632 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
633 if (
634 isNewTerminal &&
635 normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT
636 ) {
637 return normalizedThread;
638 }
619 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)639 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
620 ? normalizedThread.terminalIds640 ? normalizedThread.terminalIds
621 : [...normalizedThread.terminalIds, action.terminalId];641 : [...normalizedThread.terminalIds, action.terminalId];
apps/web/src/types.ts+1
...@@ -5,6 +5,7 @@ export type RuntimeMode = "approval-required" | "full-access";...@@ -5,6 +5,7 @@ export type RuntimeMode = "approval-required" | "full-access";
5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
6export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;6export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
7export const DEFAULT_THREAD_TERMINAL_ID = "default";7export const DEFAULT_THREAD_TERMINAL_ID = "default";
8export const MAX_THREAD_TERMINAL_COUNT = 4;
89
9export interface ThreadTerminalGroup {10export interface ThreadTerminalGroup {
10 id: string;11 id: string;