From 376efdd3401235dbba95474689e7c092aa08b858 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 17 Feb 2026 12:10:06 -0800 Subject: [PATCH] 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 --- apps/web/src/components/ChatView.tsx | 12 ++-- .../src/components/ThreadTerminalDrawer.tsx | 68 ++++++++++++------ apps/web/src/store.test.ts | 70 ++++++++++++++++++- apps/web/src/store.ts | 24 ++++++- apps/web/src/types.ts | 1 + 5 files changed, 146 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 31b0204f90f378b3c4550e997686ae87d8654f58..7a29f57e080b2300a58b297c40d8a33b632756fe 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -51,7 +51,7 @@ import { } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { useStore } from "../store"; -import type { ChatImageAttachment } from "../types"; +import { MAX_THREAD_TERMINAL_COUNT, type ChatImageAttachment } from "../types"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { @@ -294,6 +294,8 @@ export default function ChatView() { (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); + const hasReachedTerminalLimit = + (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { for (const image of images) { @@ -322,23 +324,23 @@ export default function ChatView() { }); }, [activeThread?.terminalOpen, activeThreadId, dispatch]); const splitTerminal = useCallback(() => { - if (!activeThreadId) return; + if (!activeThreadId || hasReachedTerminalLimit) return; dispatch({ type: "SPLIT_THREAD_TERMINAL", threadId: activeThreadId, terminalId: `terminal-${crypto.randomUUID()}`, }); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, dispatch]); + }, [activeThreadId, dispatch, hasReachedTerminalLimit]); const createNewTerminal = useCallback(() => { - if (!activeThreadId) return; + if (!activeThreadId || hasReachedTerminalLimit) return; dispatch({ type: "NEW_THREAD_TERMINAL", threadId: activeThreadId, terminalId: `terminal-${crypto.randomUUID()}`, }); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, dispatch]); + }, [activeThreadId, dispatch, hasReachedTerminalLimit]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1c35459a9b11fab296db05e6cf7485f008df48d1..eaf05571cb868acf825b5ff5a9bf5131dd9b70b2 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -22,6 +22,7 @@ import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keyb import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, + MAX_THREAD_TERMINAL_COUNT, type ThreadTerminalGroup, } from "../types"; @@ -581,6 +582,7 @@ export default function ThreadTerminalDrawer({ const showGroupHeaders = resolvedTerminalGroups.length > 1 || resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); + const hasReachedTerminalLimit = normalizedTerminalIds.length >= MAX_THREAD_TERMINAL_COUNT; const terminalLabelById = useMemo( () => new Map( @@ -588,9 +590,23 @@ export default function ThreadTerminalDrawer({ ), [normalizedTerminalIds], ); + const splitTerminalActionLabel = hasReachedTerminalLimit + ? `Split Terminal (max ${MAX_THREAD_TERMINAL_COUNT})` + : (splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"); + const newTerminalActionLabel = hasReachedTerminalLimit + ? `New Terminal (max ${MAX_THREAD_TERMINAL_COUNT})` + : (newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"); const closeTerminalActionLabel = closeShortcutLabel ? `Close Terminal (${closeShortcutLabel})` : "Close Terminal"; + const onSplitTerminalAction = useCallback(() => { + if (hasReachedTerminalLimit) return; + onSplitTerminal(); + }, [hasReachedTerminalLimit, onSplitTerminal]); + const onNewTerminalAction = useCallback(() => { + if (hasReachedTerminalLimit) return; + onNewTerminal(); + }, [hasReachedTerminalLimit, onNewTerminal]); useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -685,7 +701,7 @@ export default function ThreadTerminalDrawer({ return (