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 {
5151} from "../session-logic";
5252import { isScrollContainerNearBottom } from "../chat-scroll";
5353import { useStore } from "../store";
54import type { ChatImageAttachment } from "../types";
54import { MAX_THREAD_TERMINAL_COUNT, type ChatImageAttachment } from "../types";
5555import BranchToolbar from "./BranchToolbar";
5656import GitActionsControl from "./GitActionsControl";
5757import {
......@@ -294,6 +294,8 @@ export default function ChatView() {
294294 (activeThread.messages.length > 0 ||
295295 (activeThread.session !== null && activeThread.session.status !== "closed")),
296296 );
297 const hasReachedTerminalLimit =
298 (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;
297299
298300 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
299301 for (const image of images) {
......@@ -322,23 +324,23 @@ export default function ChatView() {
322324 });
323325 }, [activeThread?.terminalOpen, activeThreadId, dispatch]);
324326 const splitTerminal = useCallback(() => {
325 if (!activeThreadId) return;
327 if (!activeThreadId || hasReachedTerminalLimit) return;
326328 dispatch({
327329 type: "SPLIT_THREAD_TERMINAL",
328330 threadId: activeThreadId,
329331 terminalId: `terminal-${crypto.randomUUID()}`,
330332 });
331333 setTerminalFocusRequestId((value) => value + 1);
332 }, [activeThreadId, dispatch]);
334 }, [activeThreadId, dispatch, hasReachedTerminalLimit]);
333335 const createNewTerminal = useCallback(() => {
334 if (!activeThreadId) return;
336 if (!activeThreadId || hasReachedTerminalLimit) return;
335337 dispatch({
336338 type: "NEW_THREAD_TERMINAL",
337339 threadId: activeThreadId,
338340 terminalId: `terminal-${crypto.randomUUID()}`,
339341 });
340342 setTerminalFocusRequestId((value) => value + 1);
341 }, [activeThreadId, dispatch]);
343 }, [activeThreadId, dispatch, hasReachedTerminalLimit]);
342344 const activateTerminal = useCallback(
343345 (terminalId: string) => {
344346 if (!activeThreadId) return;
apps/web/src/components/ThreadTerminalDrawer.tsx+47-21
......@@ -22,6 +22,7 @@ import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keyb
2222import {
2323 DEFAULT_THREAD_TERMINAL_HEIGHT,
2424 DEFAULT_THREAD_TERMINAL_ID,
25 MAX_THREAD_TERMINAL_COUNT,
2526 type ThreadTerminalGroup,
2627} from "../types";
2728
......@@ -581,6 +582,7 @@ export default function ThreadTerminalDrawer({
581582 const showGroupHeaders =
582583 resolvedTerminalGroups.length > 1 ||
583584 resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1);
585 const hasReachedTerminalLimit = normalizedTerminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
584586 const terminalLabelById = useMemo(
585587 () =>
586588 new Map(
......@@ -588,9 +590,23 @@ export default function ThreadTerminalDrawer({
588590 ),
589591 [normalizedTerminalIds],
590592 );
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");
591599 const closeTerminalActionLabel = closeShortcutLabel
592600 ? `Close Terminal (${closeShortcutLabel})`
593601 : "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
595611 useEffect(() => {
596612 onHeightChangeRef.current = onHeightChange;
......@@ -685,7 +701,7 @@ export default function ThreadTerminalDrawer({
685701
686702 return (
687703 <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"
689705 style={{ height: `${drawerHeight}px` }}
690706 >
691707 <div
......@@ -700,19 +716,25 @@ export default function ThreadTerminalDrawer({
700716 <div className="pointer-events-none absolute right-2 top-2 z-20">
701717 <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70">
702718 <TerminalActionButton
703 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
704 onClick={onSplitTerminal}
705 label={
706 splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"
707 }
719 className={`p-1 text-foreground/90 transition-colors ${
720 hasReachedTerminalLimit
721 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
722 : "hover:bg-accent"
723 }`}
724 onClick={onSplitTerminalAction}
725 label={splitTerminalActionLabel}
708726 >
709727 <SquareSplitHorizontal className="size-3.25" />
710728 </TerminalActionButton>
711729 <div className="h-4 w-px bg-border/80" />
712730 <TerminalActionButton
713 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
714 onClick={onNewTerminal}
715 label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
731 className={`p-1 text-foreground/90 transition-colors ${
732 hasReachedTerminalLimit
733 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
734 : "hover:bg-accent"
735 }`}
736 onClick={onNewTerminalAction}
737 label={newTerminalActionLabel}
716738 >
717739 <Plus className="size-3.25" />
718740 </TerminalActionButton>
......@@ -733,9 +755,9 @@ export default function ThreadTerminalDrawer({
733755 <div className="min-w-0 flex-1">
734756 {isSplitView ? (
735757 <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"
737759 style={{
738 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(260px, 1fr))`,
760 gridTemplateColumns: `repeat(${visibleTerminalIds.length}, minmax(0, 1fr))`,
739761 }}
740762 >
741763 {visibleTerminalIds.map((terminalId) => (
......@@ -787,20 +809,24 @@ export default function ThreadTerminalDrawer({
787809 <div className="flex h-[22px] items-stretch justify-end border-b border-border/70">
788810 <div className="inline-flex h-full items-stretch">
789811 <TerminalActionButton
790 className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70"
791 onClick={onSplitTerminal}
792 label={
793 splitShortcutLabel
794 ? `Split Terminal (${splitShortcutLabel})`
795 : "Split Terminal"
796 }
812 className={`inline-flex h-full items-center px-1 text-foreground/90 transition-colors ${
813 hasReachedTerminalLimit
814 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
815 : "hover:bg-accent/70"
816 }`}
817 onClick={onSplitTerminalAction}
818 label={splitTerminalActionLabel}
797819 >
798820 <SquareSplitHorizontal className="size-3.25" />
799821 </TerminalActionButton>
800822 <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"
802 onClick={onNewTerminal}
803 label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
823 className={`inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors ${
824 hasReachedTerminalLimit
825 ? "cursor-not-allowed opacity-45 hover:bg-transparent"
826 : "hover:bg-accent/70"
827 }`}
828 onClick={onNewTerminalAction}
829 label={newTerminalActionLabel}
804830 >
805831 <Plus className="size-3.25" />
806832 </TerminalActionButton>
apps/web/src/store.test.ts+69-1
......@@ -2,7 +2,11 @@ import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/con
22import { describe, expect, it } from "vitest";
33
44import { 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";
610import type { Thread } from "./types";
711
812type TerminalStartedEvent = Extract<TerminalEvent, { type: "started" }>;
......@@ -255,6 +259,70 @@ describe("store reducer thread continuity", () => {
255259 expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`);
256260 });
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
258326 it("closes a terminal and keeps grouped layout coherent", () => {
259327 const state = makeState(
260328 makeThread({
apps/web/src/store.ts+22-2
......@@ -16,6 +16,7 @@ import {
1616 type ChatAttachment,
1717 DEFAULT_THREAD_TERMINAL_ID,
1818 DEFAULT_RUNTIME_MODE,
19 MAX_THREAD_TERMINAL_COUNT,
1920 type Project,
2021 type RuntimeMode,
2122 type Thread,
......@@ -110,7 +111,12 @@ function readPersistedState(): AppState {
110111 const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1);
111112 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 };
114120 } catch {
115121 return initialState;
116122 }
......@@ -139,7 +145,7 @@ function updateThread(
139145
140146function normalizeTerminalIds(terminalIds: string[]): string[] {
141147 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);
143149 if (unique.length > 0) {
144150 return unique;
145151 }
......@@ -550,6 +556,13 @@ export function reducer(state: AppState, action: Action): AppState {
550556 ...state,
551557 threads: updateThread(state.threads, action.threadId, (thread) => {
552558 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 }
553566 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
554567 ? normalizedThread.terminalIds
555568 : [...normalizedThread.terminalIds, action.terminalId];
......@@ -616,6 +629,13 @@ export function reducer(state: AppState, action: Action): AppState {
616629 ...state,
617630 threads: updateThread(state.threads, action.threadId, (thread) => {
618631 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 }
619639 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
620640 ? normalizedThread.terminalIds
621641 : [...normalizedThread.terminalIds, action.terminalId];
apps/web/src/types.ts+1
......@@ -5,6 +5,7 @@ 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 const MAX_THREAD_TERMINAL_COUNT = 4;
89
910export interface ThreadTerminalGroup {
1011 id: string;