authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 10:58:19-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:13:19-08:00
logf84f2797383fc6e586bb6e5d5b2eb18631152fb1
treea2c309a7b423cc7453617e5d861686e20715588c
parent246624ad3cce74f0fc14e8b98eb23b2fc1db585c

Refactor Zustand store usage to granular selectors

- Replace combined `useStore` state/dispatch wrapper with direct selector/action hooks across chat UI components - Inline and harden terminal state transitions inside `terminalStateStore` and remove `threadTerminalState` - Add `terminalStateStore` action tests covering defaults, splits, groups, activity, and close/reset behavior

12 files changed, 566 insertions(+), 613 deletions(-)

apps/web/src/components/BranchToolbar.tsx+10-5
...@@ -43,8 +43,10 @@ export default function BranchToolbar({...@@ -43,8 +43,10 @@ export default function BranchToolbar({
43 envLocked,43 envLocked,
44 onComposerFocusRequest,44 onComposerFocusRequest,
45}: BranchToolbarProps) {45}: BranchToolbarProps) {
46 const setThreadBranchAction = useStore((state) => state.setThreadBranch);46 const threads = useStore((store) => store.threads);
47 const setThreadErrorAction = useStore((state) => state.setThreadError);47 const projects = useStore((store) => store.projects);
48 const setThreadBranchAction = useStore((store) => store.setThreadBranch);
49 const setThreadErrorAction = useStore((store) => store.setError);
48 const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId));50 const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId));
49 const serverThread = useStore((state) =>51 const serverThread = useStore((state) =>
50 state.threads.find((thread) => thread.id === threadId),52 state.threads.find((thread) => thread.id === threadId),
...@@ -59,6 +61,9 @@ export default function BranchToolbar({...@@ -59,6 +61,9 @@ export default function BranchToolbar({
59 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);61 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
60 const [branchQuery, setBranchQuery] = useState("");62 const [branchQuery, setBranchQuery] = useState("");
6163
64 const serverThread = threads.find((thread) => thread.id === threadId);
65 const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null;
66 const activeProject = projects.find((project) => project.id === activeProjectId);
62 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);67 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
63 const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;68 const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
64 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;69 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
...@@ -119,7 +124,7 @@ export default function BranchToolbar({...@@ -119,7 +124,7 @@ export default function BranchToolbar({
119 });124 });
120 }125 }
121 if (hasServerThread) {126 if (hasServerThread) {
122 dispatch.setThreadBranch(activeThreadId, syncedBranch, null);127 setThreadBranchAction(activeThreadId, syncedBranch, null);
123 return;128 return;
124 }129 }
125 setDraftThreadContext(threadId, {130 setDraftThreadContext(threadId, {
...@@ -142,7 +147,7 @@ export default function BranchToolbar({...@@ -142,7 +147,7 @@ export default function BranchToolbar({
142147
143 const setThreadError = (error: string | null) => {148 const setThreadError = (error: string | null) => {
144 if (!activeThreadId) return;149 if (!activeThreadId) return;
145 dispatch.setError(activeThreadId, error);150 setThreadErrorAction(activeThreadId, error);
146 };151 };
147152
148 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {153 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
...@@ -171,7 +176,7 @@ export default function BranchToolbar({...@@ -171,7 +176,7 @@ export default function BranchToolbar({
171 });176 });
172 }177 }
173 if (hasServerThread) {178 if (hasServerThread) {
174 dispatch.setThreadBranch(activeThreadId, branch, worktreePath);179 setThreadBranchAction(activeThreadId, branch, worktreePath);
175 return;180 return;
176 }181 }
177 setDraftThreadContext(threadId, {182 setDraftThreadContext(threadId, {
apps/web/src/components/ChatView.tsx+32-38
...@@ -124,7 +124,7 @@ import {...@@ -124,7 +124,7 @@ import {
124 useComposerDraftStore,124 useComposerDraftStore,
125 useComposerThreadDraft,125 useComposerThreadDraft,
126} from "../composerDraftStore";126} from "../composerDraftStore";
127import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";127import { useTerminalStateStore } from "../terminalStateStore";
128import { clamp } from "effect/Number";128import { clamp } from "effect/Number";
129129
130function formatMessageMeta(createdAt: string, duration: string | null): string {130function formatMessageMeta(createdAt: string, duration: string | null): string {
...@@ -437,17 +437,13 @@ interface ChatViewProps {...@@ -437,17 +437,13 @@ interface ChatViewProps {
437}437}
438438
439export default function ChatView({ threadId }: ChatViewProps) {439export default function ChatView({ threadId }: ChatViewProps) {
440 const markThreadVisited = useStore((state) => state.markThreadVisited);440 const threads = useStore((store) => store.threads);
441 const setThreadTerminalOpen = useStore((state) => state.setThreadTerminalOpen);441 const projects = useStore((store) => store.projects);
442 const splitThreadTerminalAction = useStore((state) => state.splitThreadTerminal);442 const runtimeMode = useStore((store) => store.runtimeMode);
443 const newThreadTerminalAction = useStore((state) => state.newThreadTerminal);443 const markThreadVisited = useStore((store) => store.markThreadVisited);
444 const setThreadActiveTerminal = useStore((state) => state.setThreadActiveTerminal);444 const setStoreThreadError = useStore((store) => store.setError);
445 const closeThreadTerminalAction = useStore((state) => state.closeThreadTerminal);445 const setStoreRuntimeMode = useStore((store) => store.setRuntimeMode);
446 const setThreadErrorAction = useStore((state) => state.setThreadError);446 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
447 const setThreadBranchAction = useStore((state) => state.setThreadBranch);
448 const setRuntimeMode = useStore((state) => state.setRuntimeMode);
449 const setThreadTerminalHeight = useStore((state) => state.setThreadTerminalHeight);
450 const runtimeMode = useStore((state) => state.runtimeMode);
451 const { settings } = useAppSettings();447 const { settings } = useAppSettings();
452 const navigate = useNavigate();448 const navigate = useNavigate();
453 const rawSearch = useSearch({449 const rawSearch = useSearch({
...@@ -530,9 +526,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -530,9 +526,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
530 setMessagesScrollElement(element);526 setMessagesScrollElement(element);
531 }, []);527 }, []);
532528
533 const terminalState = useTerminalStateStore((state) =>529 const terminalState = useTerminalStateStore((state) => state.getTerminalState(threadId));
534 selectThreadTerminalState(state.terminalStateByThreadId, threadId),
535 );
536 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);530 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
537 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);531 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);
538 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);532 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
...@@ -571,6 +565,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -571,6 +565,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
571 [removeComposerDraftImage, threadId],565 [removeComposerDraftImage, threadId],
572 );566 );
573567
568 const serverThread = threads.find((t) => t.id === threadId);
569 const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId);
574 const localDraftError = localDraftErrorsByThreadId[threadId] ?? null;570 const localDraftError = localDraftErrorsByThreadId[threadId] ?? null;
575 const localDraftThread = useMemo(571 const localDraftThread = useMemo(
576 () =>572 () =>
...@@ -595,10 +591,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -595,10 +591,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
595 const activeThreadId = activeThread?.id ?? null;591 const activeThreadId = activeThread?.id ?? null;
596 const activeLatestTurn = activeThread?.latestTurn ?? null;592 const activeLatestTurn = activeThread?.latestTurn ?? null;
597 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);593 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
598 const activeProjectId = activeThread?.projectId ?? null;594 const activeProject = projects.find((p) => p.id === activeThread?.projectId);
599 const activeProject = useStore((state) =>
600 activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined,
601 );
602595
603 useEffect(() => {596 useEffect(() => {
604 if (!serverThread) {597 if (!serverThread) {
...@@ -622,7 +615,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -622,7 +615,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
622 const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN;615 const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN;
623 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;616 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;
624617
625 dispatch.markThreadVisited(activeThread.id);618 markThreadVisited(activeThread.id);
626 }, [619 }, [
627 activeThread?.id,620 activeThread?.id,
628 activeThread?.lastVisitedAt,621 activeThread?.lastVisitedAt,
...@@ -983,8 +976,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -983,8 +976,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
983 const setThreadError = useCallback(976 const setThreadError = useCallback(
984 (targetThreadId: ThreadId | null, error: string | null) => {977 (targetThreadId: ThreadId | null, error: string | null) => {
985 if (!targetThreadId) return;978 if (!targetThreadId) return;
986 if (state.threads.some((thread) => thread.id === targetThreadId)) {979 if (threads.some((thread) => thread.id === targetThreadId)) {
987 dispatch.setError(targetThreadId, error);980 setStoreThreadError(targetThreadId, error);
988 return;981 return;
989 }982 }
990 setLocalDraftErrorsByThreadId((existing) => {983 setLocalDraftErrorsByThreadId((existing) => {
...@@ -997,7 +990,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -997,7 +990,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
997 };990 };
998 });991 });
999 },992 },
1000 [dispatch, state.threads],993 [setStoreThreadError, threads],
1001 );994 );
1002995
1003 const focusComposer = useCallback(() => {996 const focusComposer = useCallback(() => {
...@@ -1155,7 +1148,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1155,7 +1148,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
1155 activeProject,1148 activeProject,
1156 activeThread,1149 activeThread,
1157 activeThreadId,1150 activeThreadId,
1158 dispatch,
1159 gitCwd,1151 gitCwd,
1160 isLocalDraftThread,1152 isLocalDraftThread,
1161 isServerThread,1153 isServerThread,
...@@ -1266,15 +1258,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1266,15 +1258,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
1266 );1258 );
12671259
1268 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {1260 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
1269 if (mode === state.runtimeMode) return;1261 if (mode === runtimeMode) return;
1270 dispatch.setRuntimeMode(mode);1262 setStoreRuntimeMode(mode);
1271 scheduleComposerFocus();1263 scheduleComposerFocus();
1272 const api = readNativeApi();1264 const api = readNativeApi();
1273 if (!api) return;1265 if (!api) return;
12741266
1275 const runningThreadIds = useStore1267 const runningThreadIds = threads
1276 .getState()
1277 .threads
1278 .filter((thread) => thread.session !== null && thread.session.status !== "closed")1268 .filter((thread) => thread.session !== null && thread.session.status !== "closed")
1279 .map((thread) => thread.id);1269 .map((thread) => thread.id);
12801270
...@@ -1891,7 +1881,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1891,7 +1881,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1891 const shouldCreateWorktree =1881 const shouldCreateWorktree =
1892 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;1882 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
1893 if (shouldCreateWorktree && !activeThread.branch) {1883 if (shouldCreateWorktree && !activeThread.branch) {
1894 dispatch.setError(threadIdForSend, "Select a base branch before sending in New worktree mode.");1884 setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
1895 return;1885 return;
1896 }1886 }
18971887
...@@ -1964,7 +1954,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1964,7 +1954,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1964 });1954 });
1965 // Keep local thread state in sync immediately so terminal drawer opens1955 // Keep local thread state in sync immediately so terminal drawer opens
1966 // with the worktree cwd/env instead of briefly using the project root.1956 // with the worktree cwd/env instead of briefly using the project root.
1967 dispatch.setThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path);1957 setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path);
1968 }1958 }
1969 }1959 }
19701960
...@@ -2015,7 +2005,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2015,7 +2005,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
2015 setSendPhase("sending-turn");2005 setSendPhase("sending-turn");
2016 const turnAttachments = await turnAttachmentsPromise;2006 const turnAttachments = await turnAttachmentsPromise;
2017 const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request";2007 const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request";
2018 const sandboxMode = runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";2008 const sandboxMode =
2009 runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
2019 await api.orchestration.dispatchCommand({2010 await api.orchestration.dispatchCommand({
2020 type: "thread.turn.start",2011 type: "thread.turn.start",
2021 commandId: newCommandId(),2012 commandId: newCommandId(),
...@@ -2103,13 +2094,16 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2103,13 +2094,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
2103 createdAt: new Date().toISOString(),2094 createdAt: new Date().toISOString(),
2104 });2095 });
2105 } catch (err) {2096 } catch (err) {
2106 dispatch.setError(activeThreadId, err instanceof Error ? err.message : "Failed to submit approval decision.");2097 setStoreThreadError(
2098 activeThreadId,
2099 err instanceof Error ? err.message : "Failed to submit approval decision.",
2100 );
2107 } finally {2101 } finally {
2108 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));2102 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
2109 }2103 }
2110 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));2104 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
2111 },2105 },
2112 [activeThreadId, setThreadErrorAction],2106 [activeThreadId, setStoreThreadError],
2113 );2107 );
21142108
2115 const onModelSelect = useCallback(2109 const onModelSelect = useCallback(
...@@ -3544,12 +3538,12 @@ const OpenInPicker = memo(function OpenInPicker({...@@ -3544,12 +3538,12 @@ const OpenInPicker = memo(function OpenInPicker({
3544 ] satisfies { label: string; Icon: Icon; value: EditorId }[];3538 ] satisfies { label: string; Icon: Icon; value: EditorId }[];
3545 const primaryOption = options.find(({ value }) => value === lastEditor);3539 const primaryOption = options.find(({ value }) => value === lastEditor);
35463540
3547 const activeThread = useStore((state) =>3541 const activeThread = useStore((store) =>
3548 activeThreadId ? state.threads.find((thread) => thread.id === activeThreadId) : undefined,3542 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
3549 );3543 );
3550 const activeProjectId = activeThread?.projectId ?? null;3544 const activeProjectId = activeThread?.projectId ?? null;
3551 const activeProject = useStore((state) =>3545 const activeProject = useStore((store) =>
3552 activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined,3546 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
3553 );3547 );
35543548
3555 const openInEditor = useCallback(3549 const openInEditor = useCallback(
apps/web/src/components/DiffPanel.tsx+6-7
...@@ -166,15 +166,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -166,15 +166,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
166 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),166 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
167 });167 });
168 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });168 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });
169 const { state } = useStore();
170 const activeThreadId = routeThreadId;169 const activeThreadId = routeThreadId;
171 const activeThread = activeThreadId170 const activeThread = useStore((store) =>
172 ? state.threads.find((thread) => thread.id === activeThreadId)171 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
173 : undefined;172 );
174 const activeProjectId = activeThread?.projectId ?? null;173 const activeProjectId = activeThread?.projectId ?? null;
175 const activeProject = activeProjectId174 const activeProject = useStore((store) =>
176 ? state.projects.find((project) => project.id === activeProjectId)175 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
177 : undefined;176 );
178 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;177 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
179 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =178 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
180 useTurnDiffSummaries(activeThread);179 useTurnDiffSummaries(activeThread);
apps/web/src/components/Sidebar.tsx+13-13
...@@ -21,7 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/...@@ -21,7 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/
21import { serverConfigQueryOptions } from "../lib/serverReactQuery";21import { serverConfigQueryOptions } from "../lib/serverReactQuery";
22import { readNativeApi } from "../nativeApi";22import { readNativeApi } from "../nativeApi";
23import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";23import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
24import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";24import { useTerminalStateStore } from "../terminalStateStore";
25import { toastManager } from "./ui/toast";25import { toastManager } from "./ui/toast";
26import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";26import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
27import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";27import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
...@@ -231,16 +231,16 @@ function ProjectFavicon({ cwd }: { cwd: string }) {...@@ -231,16 +231,16 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
231}231}
232232
233export default function Sidebar() {233export default function Sidebar() {
234 const projects = useStore((state) => state.projects);234 const projects = useStore((store) => store.projects);
235 const threads = useStore((state) => state.threads);235 const threads = useStore((store) => store.threads);
236 const markThreadUnread = useStore((state) => state.markThreadUnread);236 const markThreadUnread = useStore((store) => store.markThreadUnread);
237 const toggleProject = useStore((state) => state.toggleProject);237 const toggleProject = useStore((store) => store.toggleProject);
238 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);238 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
239 const getDraftThreadByProjectId = useComposerDraftStore(239 const getDraftThreadByProjectId = useComposerDraftStore(
240 (store) => store.getDraftThreadByProjectId,240 (store) => store.getDraftThreadByProjectId,
241 );241 );
242 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);242 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
243 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);243 const getTerminalState = useTerminalStateStore((state) => state.getTerminalState);
244 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);244 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
245 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);245 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
246 const clearProjectDraftThreadId = useComposerDraftStore(246 const clearProjectDraftThreadId = useComposerDraftStore(
...@@ -445,7 +445,8 @@ export default function Sidebar() {...@@ -445,7 +445,8 @@ export default function Sidebar() {
445 const api = readNativeApi();445 const api = readNativeApi();
446446
447 setIsAddingProject(true);447 setIsAddingProject(true);
448 if (api) {448 try {
449 if (!api) return;
449 const existing = projects.find((project) => project.cwd === cwd);450 const existing = projects.find((project) => project.cwd === cwd);
450 if (existing) {451 if (existing) {
451 focusMostRecentThreadForProject(existing.id);452 focusMostRecentThreadForProject(existing.id);
...@@ -509,7 +510,7 @@ export default function Sidebar() {...@@ -509,7 +510,7 @@ export default function Sidebar() {
509 position,510 position,
510 );511 );
511 if (clicked === "mark-unread") {512 if (clicked === "mark-unread") {
512 dispatch.markThreadUnread(threadId);513 markThreadUnread(threadId);
513 return;514 return;
514 }515 }
515 if (clicked !== "delete") return;516 if (clicked !== "delete") return;
...@@ -616,9 +617,9 @@ export default function Sidebar() {...@@ -616,9 +617,9 @@ export default function Sidebar() {
616 clearProjectDraftThreadById,617 clearProjectDraftThreadById,
617 markThreadUnread,618 markThreadUnread,
618 navigate,619 navigate,
620 projects,
619 removeWorktreeMutation,621 removeWorktreeMutation,
620 routeThreadId,622 routeThreadId,
621 projects,
622 threads,623 threads,
623 ],624 ],
624 );625 );
...@@ -709,7 +710,7 @@ export default function Sidebar() {...@@ -709,7 +710,7 @@ export default function Sidebar() {
709 return () => {710 return () => {
710 window.removeEventListener("keydown", onWindowKeyDown);711 window.removeEventListener("keydown", onWindowKeyDown);
711 };712 };
712 }, [getDraftThread, handleNewThread, keybindings, routeThreadId, projects, threads]);713 }, [getDraftThread, handleNewThread, keybindings, projects, routeThreadId, threads]);
713714
714 const onCreateThreadClick = () => {715 const onCreateThreadClick = () => {
715 if (projects.length === 0) {716 if (projects.length === 0) {
...@@ -781,7 +782,7 @@ export default function Sidebar() {...@@ -781,7 +782,7 @@ export default function Sidebar() {
781 open={project.expanded}782 open={project.expanded}
782 onOpenChange={(open) => {783 onOpenChange={(open) => {
783 if (open === project.expanded) return;784 if (open === project.expanded) return;
784 dispatch.toggleProject(project.id);785 toggleProject(project.id);
785 }}786 }}
786 >787 >
787 <SidebarMenuItem>788 <SidebarMenuItem>
...@@ -824,8 +825,7 @@ export default function Sidebar() {...@@ -824,8 +825,7 @@ export default function Sidebar() {
824 );825 );
825 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);826 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
826 const terminalStatus = terminalStatusFromRunningIds(827 const terminalStatus = terminalStatusFromRunningIds(
827 selectThreadTerminalState(terminalStateByThreadId, thread.id)828 getTerminalState(thread.id).runningTerminalIds,
828 .runningTerminalIds,
829 );829 );
830830
831 return (831 return (
apps/web/src/routes/__root.tsx+5-6
...@@ -124,9 +124,8 @@ function errorDetails(error: unknown): string {...@@ -124,9 +124,8 @@ function errorDetails(error: unknown): string {
124}124}
125125
126function EventRouter() {126function EventRouter() {
127 const syncServerReadModel = useStore((state) => state.syncServerReadModel);127 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
128 const setThreadTerminalActivity = useStore((state) => state.setThreadTerminalActivity);128 const setProjectExpanded = useStore((store) => store.setProjectExpanded);
129 const setProjectExpanded = useStore((state) => state.setProjectExpanded);
130 const queryClient = useQueryClient();129 const queryClient = useQueryClient();
131 const navigate = useNavigate();130 const navigate = useNavigate();
132 const pathname = useRouterState({ select: (state) => state.location.pathname });131 const pathname = useRouterState({ select: (state) => state.location.pathname });
...@@ -150,7 +149,7 @@ function EventRouter() {...@@ -150,7 +149,7 @@ function EventRouter() {
150 const snapshot = await api.orchestration.getSnapshot();149 const snapshot = await api.orchestration.getSnapshot();
151 if (disposed) return;150 if (disposed) return;
152 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);151 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
153 dispatch.syncServerReadModel(snapshot);152 syncServerReadModel(snapshot);
154 if (pending) {153 if (pending) {
155 pending = false;154 pending = false;
156 await flushSnapshotSync();155 await flushSnapshotSync();
...@@ -205,7 +204,7 @@ function EventRouter() {...@@ -205,7 +204,7 @@ function EventRouter() {
205 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {204 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
206 return;205 return;
207 }206 }
208 dispatch.setProjectExpanded(payload.bootstrapProjectId, true);207 setProjectExpanded(payload.bootstrapProjectId, true);
209208
210 if (pathnameRef.current !== "/") {209 if (pathnameRef.current !== "/") {
211 return;210 return;
...@@ -270,7 +269,7 @@ function EventRouter() {...@@ -270,7 +269,7 @@ function EventRouter() {
270 unsubWelcome();269 unsubWelcome();
271 unsubServerConfigUpdated();270 unsubServerConfigUpdated();
272 };271 };
273 }, [navigate, queryClient, setProjectExpanded, setThreadTerminalActivity, syncServerReadModel]);272 }, [navigate, queryClient, setProjectExpanded, syncServerReadModel]);
274273
275 return null;274 return null;
276}275}
apps/web/src/routes/_chat.$threadId.tsx+2-3
...@@ -133,14 +133,13 @@ const DiffPanelInlineSidebar = (props: {...@@ -133,14 +133,13 @@ const DiffPanelInlineSidebar = (props: {
133};133};
134134
135function ChatThreadRouteView() {135function ChatThreadRouteView() {
136 const { state } = useStore();136 const threadsHydrated = useStore((store) => store.threadsHydrated);
137 const threadsHydrated = state.threadsHydrated;
138 const navigate = useNavigate();137 const navigate = useNavigate();
139 const threadId = Route.useParams({138 const threadId = Route.useParams({
140 select: (params) => ThreadId.makeUnsafe(params.threadId),139 select: (params) => ThreadId.makeUnsafe(params.threadId),
141 });140 });
142 const search = Route.useSearch();141 const search = Route.useSearch();
143 const threadExists = state.threads.some((thread) => thread.id === threadId);142 const threadExists = useStore((store) => store.threads.some((thread) => thread.id === threadId));
144 const draftThreadExists = useComposerDraftStore(143 const draftThreadExists = useComposerDraftStore(
145 (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId),144 (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId),
146 );145 );
apps/web/src/routes/_chat.settings.tsx+3-3
...@@ -48,8 +48,8 @@ const RUNTIME_MODE_OPTIONS = [...@@ -48,8 +48,8 @@ const RUNTIME_MODE_OPTIONS = [
4848
49function SettingsRouteView() {49function SettingsRouteView() {
50 const { theme, setTheme, resolvedTheme } = useTheme();50 const { theme, setTheme, resolvedTheme } = useTheme();
51 const runtimeMode = useStore((state) => state.runtimeMode);51 const runtimeMode = useStore((store) => store.runtimeMode);
52 const setRuntimeMode = useStore((state) => state.setRuntimeMode);52 const setRuntimeMode = useStore((store) => store.setRuntimeMode);
53 const { settings, defaults, updateSettings } = useAppSettings();53 const { settings, defaults, updateSettings } = useAppSettings();
54 const serverConfigQuery = useQuery(serverConfigQueryOptions());54 const serverConfigQuery = useQuery(serverConfigQueryOptions());
55 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);55 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
...@@ -349,7 +349,7 @@ function SettingsRouteView() {...@@ -349,7 +349,7 @@ function SettingsRouteView() {
349 : "border-border bg-background text-muted-foreground hover:bg-accent"349 : "border-border bg-background text-muted-foreground hover:bg-accent"
350 }`}350 }`}
351 onClick={() => {351 onClick={() => {
352 dispatch.setRuntimeMode(option.value);352 setRuntimeMode(option.value);
353 }}353 }}
354 >354 >
355 <span className="flex flex-col">355 <span className="flex flex-col">
apps/web/src/store.ts+3-23
...@@ -400,7 +400,7 @@ interface AppStore extends AppState {...@@ -400,7 +400,7 @@ interface AppStore extends AppState {
400 setRuntimeMode: (mode: RuntimeMode) => void;400 setRuntimeMode: (mode: RuntimeMode) => void;
401}401}
402402
403export const useAppStore = create<AppStore>((set, _get) => ({403export const useStore = create<AppStore>((set, _get) => ({
404 ...readPersistedState(),404 ...readPersistedState(),
405 syncServerReadModel: (readModel) =>405 syncServerReadModel: (readModel) =>
406 set((state) => syncServerReadModel(state, readModel)),406 set((state) => syncServerReadModel(state, readModel)),
...@@ -421,31 +421,11 @@ export const useAppStore = create<AppStore>((set, _get) => ({...@@ -421,31 +421,11 @@ export const useAppStore = create<AppStore>((set, _get) => ({
421}));421}));
422422
423// Persist on every state change (only runtimeMode + expandedProjectCwds)423// Persist on every state change (only runtimeMode + expandedProjectCwds)
424useAppStore.subscribe((state) => persistState(state));424useStore.subscribe((state) => persistState(state));
425
426// ── useStore (state + store API for call sites that want both) ────────────────
427425
428export function StoreProvider({ children }: { children: ReactNode }) {426export function StoreProvider({ children }: { children: ReactNode }) {
429 useEffect(() => {427 useEffect(() => {
430 persistState(useAppStore.getState());428 persistState(useStore.getState());
431 }, []);429 }, []);
432 return createElement(Fragment, null, children);430 return createElement(Fragment, null, children);
433}431}
434
435type UseStoreCombinedResult = {
436 state: Pick<AppStore, "projects" | "threads" | "threadsHydrated" | "runtimeMode">;
437 dispatch: AppStore;
438};
439
440export function useStore(): UseStoreCombinedResult {
441 const state = useAppStore((s) => ({
442 projects: s.projects,
443 threads: s.threads,
444 threadsHydrated: s.threadsHydrated,
445 runtimeMode: s.runtimeMode,
446 }));
447 return {
448 state,
449 dispatch: useAppStore.getState(),
450 };
451}
apps/web/src/terminalStateStore.test.ts created+89
...@@ -0,0 +1,89 @@
1import { ThreadId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it } from "vitest";
3
4import { useTerminalStateStore } from "./terminalStateStore";
5
6const THREAD_ID = ThreadId.makeUnsafe("thread-1");
7
8describe("terminalStateStore actions", () => {
9 beforeEach(() => {
10 if (typeof localStorage !== "undefined") {
11 localStorage.clear();
12 }
13 useTerminalStateStore.setState({ terminalStateByThreadId: {} });
14 });
15
16 it("returns a closed default terminal state for unknown threads", () => {
17 const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID);
18 expect(terminalState).toEqual({
19 terminalOpen: false,
20 terminalHeight: 280,
21 terminalIds: ["default"],
22 runningTerminalIds: [],
23 activeTerminalId: "default",
24 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
25 activeTerminalGroupId: "group-default",
26 });
27 });
28
29 it("opens and splits terminals into the active group", () => {
30 const store = useTerminalStateStore.getState();
31 store.setTerminalOpen(THREAD_ID, true);
32 store.splitTerminal(THREAD_ID, "terminal-2");
33
34 const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID);
35 expect(terminalState.terminalOpen).toBe(true);
36 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
37 expect(terminalState.activeTerminalId).toBe("terminal-2");
38 expect(terminalState.terminalGroups).toEqual([
39 { id: "group-default", terminalIds: ["default", "terminal-2"] },
40 ]);
41 });
42
43 it("creates new terminals in a separate group", () => {
44 useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2");
45
46 const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID);
47 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
48 expect(terminalState.activeTerminalId).toBe("terminal-2");
49 expect(terminalState.activeTerminalGroupId).toBe("group-terminal-2");
50 expect(terminalState.terminalGroups).toEqual([
51 { id: "group-default", terminalIds: ["default"] },
52 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
53 ]);
54 });
55
56 it("tracks and clears terminal subprocess activity", () => {
57 const store = useTerminalStateStore.getState();
58 store.splitTerminal(THREAD_ID, "terminal-2");
59 store.setTerminalActivity(THREAD_ID, "terminal-2", true);
60 expect(useTerminalStateStore.getState().getTerminalState(THREAD_ID).runningTerminalIds).toEqual([
61 "terminal-2",
62 ]);
63
64 store.setTerminalActivity(THREAD_ID, "terminal-2", false);
65 expect(useTerminalStateStore.getState().getTerminalState(THREAD_ID).runningTerminalIds).toEqual([]);
66 });
67
68 it("resets to default and clears persisted entry when closing the last terminal", () => {
69 const store = useTerminalStateStore.getState();
70 store.closeTerminal(THREAD_ID, "default");
71
72 expect(useTerminalStateStore.getState().terminalStateByThreadId[THREAD_ID]).toBeUndefined();
73 expect(useTerminalStateStore.getState().getTerminalState(THREAD_ID).terminalIds).toEqual(["default"]);
74 });
75
76 it("keeps a valid active terminal after closing an active split terminal", () => {
77 const store = useTerminalStateStore.getState();
78 store.splitTerminal(THREAD_ID, "terminal-2");
79 store.splitTerminal(THREAD_ID, "terminal-3");
80 store.closeTerminal(THREAD_ID, "terminal-3");
81
82 const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID);
83 expect(terminalState.activeTerminalId).toBe("terminal-2");
84 expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]);
85 expect(terminalState.terminalGroups).toEqual([
86 { id: "group-default", terminalIds: ["default", "terminal-2"] },
87 ]);
88 });
89});
apps/web/src/terminalStateStore.ts+403-18
...@@ -1,27 +1,409 @@...@@ -1,27 +1,409 @@
1/**1/**
2 * Single Zustand store for terminal UI state keyed by threadId. Used by both2 * Single Zustand store for terminal UI state keyed by threadId.
3 * persisted (server) threads and draft threads; threadId is stable for both.3 *
4 * Terminal transition helpers are intentionally private to keep the public
5 * API constrained to store actions/selectors.
4 */6 */
57
6import type { ThreadId } from "@t3tools/contracts";8import type { ThreadId } from "@t3tools/contracts";
7import { create } from "zustand";9import { create } from "zustand";
8import { createJSONStorage, persist } from "zustand/middleware";10import { createJSONStorage, persist } from "zustand/middleware";
9import {11import {
10 closeThreadTerminal,12 DEFAULT_THREAD_TERMINAL_HEIGHT,
11 getDefaultThreadTerminalState,13 DEFAULT_THREAD_TERMINAL_ID,
12 isDefaultThreadTerminalState,14 MAX_THREAD_TERMINAL_COUNT,
13 newThreadTerminal,15 type ThreadTerminalGroup,
14 setThreadActiveTerminal,16} from "./types";
15 setThreadTerminalActivity,17
16 setThreadTerminalHeight,18interface ThreadTerminalState {
17 setThreadTerminalOpen,19 terminalOpen: boolean;
18 splitThreadTerminal,20 terminalHeight: number;
19 type ThreadTerminalState,21 terminalIds: string[];
20} from "./threadTerminalState";22 runningTerminalIds: string[];
2123 activeTerminalId: string;
22export const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";24 terminalGroups: ThreadTerminalGroup[];
2325 activeTerminalGroupId: string;
24export function selectThreadTerminalState(26}
27
28const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
29
30function 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 );
35 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
36}
37
38function normalizeRunningTerminalIds(runningTerminalIds: string[], terminalIds: string[]): string[] {
39 if (runningTerminalIds.length === 0) return [];
40 const validTerminalIdSet = new Set(terminalIds);
41 return [...new Set(runningTerminalIds)]
42 .map((id) => id.trim())
43 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
44}
45
46function fallbackGroupId(terminalId: string): string {
47 return `group-${terminalId}`;
48}
49
50function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string {
51 let candidate = baseId;
52 let index = 2;
53 while (usedGroupIds.has(candidate)) {
54 candidate = `${baseId}-${index}`;
55 index += 1;
56 }
57 usedGroupIds.add(candidate);
58 return candidate;
59}
60
61function findGroupIndexByTerminalId(terminalGroups: ThreadTerminalGroup[], terminalId: string): number {
62 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
63}
64
65function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
66 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
67}
68
69function normalizeTerminalGroups(
70 terminalGroups: ThreadTerminalGroup[],
71 terminalIds: string[],
72): ThreadTerminalGroup[] {
73 const validTerminalIdSet = new Set(terminalIds);
74 const assignedTerminalIds = new Set<string>();
75 const nextGroups: ThreadTerminalGroup[] = [];
76 const usedGroupIds = new Set<string>();
77
78 for (const group of terminalGroups) {
79 const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => {
80 if (!validTerminalIdSet.has(terminalId)) return false;
81 if (assignedTerminalIds.has(terminalId)) return false;
82 return true;
83 });
84 if (groupTerminalIds.length === 0) continue;
85 for (const terminalId of groupTerminalIds) {
86 assignedTerminalIds.add(terminalId);
87 }
88 const baseGroupId =
89 group.id.trim().length > 0
90 ? group.id.trim()
91 : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
92 nextGroups.push({
93 id: assignUniqueGroupId(baseGroupId, usedGroupIds),
94 terminalIds: groupTerminalIds,
95 });
96 }
97
98 for (const terminalId of terminalIds) {
99 if (assignedTerminalIds.has(terminalId)) continue;
100 nextGroups.push({
101 id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds),
102 terminalIds: [terminalId],
103 });
104 }
105
106 if (nextGroups.length === 0) {
107 return [
108 {
109 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
110 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
111 },
112 ];
113 }
114
115 return nextGroups;
116}
117
118function arraysEqual(a: string[], b: string[]): boolean {
119 if (a.length !== b.length) return false;
120 for (let index = 0; index < a.length; index += 1) {
121 if (a[index] !== b[index]) return false;
122 }
123 return true;
124}
125
126function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean {
127 if (left.length !== right.length) return false;
128 for (let index = 0; index < left.length; index += 1) {
129 const leftGroup = left[index];
130 const rightGroup = right[index];
131 if (!leftGroup || !rightGroup) return false;
132 if (leftGroup.id !== rightGroup.id) return false;
133 if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false;
134 }
135 return true;
136}
137
138function threadTerminalStateEqual(left: ThreadTerminalState, right: ThreadTerminalState): boolean {
139 return (
140 left.terminalOpen === right.terminalOpen &&
141 left.terminalHeight === right.terminalHeight &&
142 left.activeTerminalId === right.activeTerminalId &&
143 left.activeTerminalGroupId === right.activeTerminalGroupId &&
144 arraysEqual(left.terminalIds, right.terminalIds) &&
145 arraysEqual(left.runningTerminalIds, right.runningTerminalIds) &&
146 terminalGroupsEqual(left.terminalGroups, right.terminalGroups)
147 );
148}
149
150const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({
151 terminalOpen: false,
152 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
153 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
154 runningTerminalIds: [],
155 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
156 terminalGroups: [
157 {
158 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
159 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
160 },
161 ],
162 activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
163});
164
165function createDefaultThreadTerminalState(): ThreadTerminalState {
166 return { ...DEFAULT_THREAD_TERMINAL_STATE };
167}
168
169function getDefaultThreadTerminalState(): ThreadTerminalState {
170 return DEFAULT_THREAD_TERMINAL_STATE;
171}
172
173function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState {
174 const terminalIds = normalizeTerminalIds(state.terminalIds);
175 const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID];
176 const runningTerminalIds = normalizeRunningTerminalIds(state.runningTerminalIds, nextTerminalIds);
177 const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId)
178 ? state.activeTerminalId
179 : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
180 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);
181 const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId)
182 ? state.activeTerminalGroupId
183 : null;
184 const activeGroupIdFromTerminal =
185 terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null;
186
187 const normalized: ThreadTerminalState = {
188 terminalOpen: state.terminalOpen,
189 terminalHeight:
190 Number.isFinite(state.terminalHeight) && state.terminalHeight > 0
191 ? state.terminalHeight
192 : DEFAULT_THREAD_TERMINAL_HEIGHT,
193 terminalIds: nextTerminalIds,
194 runningTerminalIds,
195 activeTerminalId,
196 terminalGroups,
197 activeTerminalGroupId:
198 activeGroupIdFromState ??
199 activeGroupIdFromTerminal ??
200 terminalGroups[0]?.id ??
201 fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
202 };
203 return threadTerminalStateEqual(state, normalized) ? state : normalized;
204}
205
206function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean {
207 const normalized = normalizeThreadTerminalState(state);
208 return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE);
209}
210
211function isValidTerminalId(terminalId: string): boolean {
212 return terminalId.trim().length > 0;
213}
214
215function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
216 return groups.map((group) => ({
217 id: group.id,
218 terminalIds: [...group.terminalIds],
219 }));
220}
221
222function upsertTerminalIntoGroups(
223 state: ThreadTerminalState,
224 terminalId: string,
225 mode: "split" | "new",
226): ThreadTerminalState {
227 const normalized = normalizeThreadTerminalState(state);
228 if (!isValidTerminalId(terminalId)) {
229 return normalized;
230 }
231
232 const isNewTerminal = !normalized.terminalIds.includes(terminalId);
233 if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
234 return normalized;
235 }
236
237 const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds;
238 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);
239
240 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);
241 if (existingGroupIndex >= 0) {
242 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter(
243 (id) => id !== terminalId,
244 );
245 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
246 terminalGroups.splice(existingGroupIndex, 1);
247 }
248 }
249
250 if (mode === "new") {
251 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
252 const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds);
253 terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });
254 return normalizeThreadTerminalState({
255 ...normalized,
256 terminalOpen: true,
257 terminalIds,
258 activeTerminalId: terminalId,
259 terminalGroups,
260 activeTerminalGroupId: nextGroupId,
261 });
262 }
263
264 let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId);
265 if (activeGroupIndex < 0) {
266 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);
267 }
268 if (activeGroupIndex < 0) {
269 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
270 const nextGroupId = assignUniqueGroupId(fallbackGroupId(normalized.activeTerminalId), usedGroupIds);
271 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });
272 activeGroupIndex = terminalGroups.length - 1;
273 }
274
275 const destinationGroup = terminalGroups[activeGroupIndex];
276 if (!destinationGroup) {
277 return normalized;
278 }
279
280 if (!destinationGroup.terminalIds.includes(terminalId)) {
281 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);
282 if (anchorIndex >= 0) {
283 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId);
284 } else {
285 destinationGroup.terminalIds.push(terminalId);
286 }
287 }
288
289 return normalizeThreadTerminalState({
290 ...normalized,
291 terminalOpen: true,
292 terminalIds,
293 activeTerminalId: terminalId,
294 terminalGroups,
295 activeTerminalGroupId: destinationGroup.id,
296 });
297}
298
299function setThreadTerminalOpen(state: ThreadTerminalState, open: boolean): ThreadTerminalState {
300 const normalized = normalizeThreadTerminalState(state);
301 if (normalized.terminalOpen === open) return normalized;
302 return { ...normalized, terminalOpen: open };
303}
304
305function setThreadTerminalHeight(state: ThreadTerminalState, height: number): ThreadTerminalState {
306 const normalized = normalizeThreadTerminalState(state);
307 if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) {
308 return normalized;
309 }
310 return { ...normalized, terminalHeight: height };
311}
312
313function splitThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState {
314 return upsertTerminalIntoGroups(state, terminalId, "split");
315}
316
317function newThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState {
318 return upsertTerminalIntoGroups(state, terminalId, "new");
319}
320
321function setThreadActiveTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState {
322 const normalized = normalizeThreadTerminalState(state);
323 if (!normalized.terminalIds.includes(terminalId)) {
324 return normalized;
325 }
326 const activeTerminalGroupId =
327 normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ??
328 normalized.activeTerminalGroupId;
329 if (
330 normalized.activeTerminalId === terminalId &&
331 normalized.activeTerminalGroupId === activeTerminalGroupId
332 ) {
333 return normalized;
334 }
335 return {
336 ...normalized,
337 activeTerminalId: terminalId,
338 activeTerminalGroupId,
339 };
340}
341
342function closeThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState {
343 const normalized = normalizeThreadTerminalState(state);
344 if (!normalized.terminalIds.includes(terminalId)) {
345 return normalized;
346 }
347
348 const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId);
349 if (remainingTerminalIds.length === 0) {
350 return createDefaultThreadTerminalState();
351 }
352
353 const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId);
354 const nextActiveTerminalId =
355 normalized.activeTerminalId === terminalId
356 ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??
357 remainingTerminalIds[0] ??
358 DEFAULT_THREAD_TERMINAL_ID)
359 : normalized.activeTerminalId;
360
361 const terminalGroups = normalized.terminalGroups
362 .map((group) => ({
363 ...group,
364 terminalIds: group.terminalIds.filter((id) => id !== terminalId),
365 }))
366 .filter((group) => group.terminalIds.length > 0);
367
368 const nextActiveTerminalGroupId =
369 terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ??
370 terminalGroups[0]?.id ??
371 fallbackGroupId(nextActiveTerminalId);
372
373 return normalizeThreadTerminalState({
374 terminalOpen: normalized.terminalOpen,
375 terminalHeight: normalized.terminalHeight,
376 terminalIds: remainingTerminalIds,
377 runningTerminalIds: normalized.runningTerminalIds.filter((id) => id !== terminalId),
378 activeTerminalId: nextActiveTerminalId,
379 terminalGroups,
380 activeTerminalGroupId: nextActiveTerminalGroupId,
381 });
382}
383
384function setThreadTerminalActivity(
385 state: ThreadTerminalState,
386 terminalId: string,
387 hasRunningSubprocess: boolean,
388): ThreadTerminalState {
389 const normalized = normalizeThreadTerminalState(state);
390 if (!normalized.terminalIds.includes(terminalId)) {
391 return normalized;
392 }
393 const alreadyRunning = normalized.runningTerminalIds.includes(terminalId);
394 if (hasRunningSubprocess === alreadyRunning) {
395 return normalized;
396 }
397 const runningTerminalIds = new Set(normalized.runningTerminalIds);
398 if (hasRunningSubprocess) {
399 runningTerminalIds.add(terminalId);
400 } else {
401 runningTerminalIds.delete(terminalId);
402 }
403 return { ...normalized, runningTerminalIds: [...runningTerminalIds] };
404}
405
406function selectThreadTerminalState(
25 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,407 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,
26 threadId: ThreadId,408 threadId: ThreadId,
27): ThreadTerminalState {409): ThreadTerminalState {
...@@ -62,6 +444,7 @@ function updateTerminalStateByThreadId(...@@ -62,6 +444,7 @@ function updateTerminalStateByThreadId(
62444
63interface TerminalStateStoreState {445interface TerminalStateStoreState {
64 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;446 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;
447 getTerminalState: (threadId: ThreadId) => ThreadTerminalState;
65 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;448 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;
66 setTerminalHeight: (threadId: ThreadId, height: number) => void;449 setTerminalHeight: (threadId: ThreadId, height: number) => void;
67 splitTerminal: (threadId: ThreadId, terminalId: string) => void;450 splitTerminal: (threadId: ThreadId, terminalId: string) => void;
...@@ -78,7 +461,7 @@ interface TerminalStateStoreState {...@@ -78,7 +461,7 @@ interface TerminalStateStoreState {
78461
79export const useTerminalStateStore = create<TerminalStateStoreState>()(462export const useTerminalStateStore = create<TerminalStateStoreState>()(
80 persist(463 persist(
81 (set) => {464 (set, get) => {
82 const updateTerminal = (465 const updateTerminal = (
83 threadId: ThreadId,466 threadId: ThreadId,
84 updater: (state: ThreadTerminalState) => ThreadTerminalState,467 updater: (state: ThreadTerminalState) => ThreadTerminalState,
...@@ -97,8 +480,10 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -97,8 +480,10 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
97 };480 };
98 });481 });
99 };482 };
483
100 return {484 return {
101 terminalStateByThreadId: {},485 terminalStateByThreadId: {},
486 getTerminalState: (threadId) => selectThreadTerminalState(get().terminalStateByThreadId, threadId),
102 setTerminalOpen: (threadId, open) =>487 setTerminalOpen: (threadId, open) =>
103 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),488 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),
104 setTerminalHeight: (threadId, height) =>489 setTerminalHeight: (threadId, height) =>
apps/web/src/threadTerminalState.test.ts deleted-69
...@@ -1,69 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 closeThreadTerminal,
5 createDefaultThreadTerminalState,
6 newThreadTerminal,
7 setThreadTerminalOpen,
8 splitThreadTerminal,
9} from "./threadTerminalState";
10
11describe("threadTerminalState", () => {
12 it("creates a closed default terminal state", () => {
13 expect(createDefaultThreadTerminalState()).toEqual({
14 terminalOpen: false,
15 terminalHeight: 280,
16 terminalIds: ["default"],
17 runningTerminalIds: [],
18 activeTerminalId: "default",
19 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
20 activeTerminalGroupId: "group-default",
21 });
22 });
23
24 it("opens and splits terminal tabs into the active group", () => {
25 const opened = setThreadTerminalOpen(createDefaultThreadTerminalState(), true);
26 const split = splitThreadTerminal(opened, "terminal-2");
27
28 expect(split.terminalOpen).toBe(true);
29 expect(split.terminalIds).toEqual(["default", "terminal-2"]);
30 expect(split.activeTerminalId).toBe("terminal-2");
31 expect(split.terminalGroups).toEqual([
32 {
33 id: "group-default",
34 terminalIds: ["default", "terminal-2"],
35 },
36 ]);
37 });
38
39 it("creates new terminals in a new group", () => {
40 const next = newThreadTerminal(createDefaultThreadTerminalState(), "terminal-2");
41
42 expect(next.terminalIds).toEqual(["default", "terminal-2"]);
43 expect(next.activeTerminalId).toBe("terminal-2");
44 expect(next.activeTerminalGroupId).toBe("group-terminal-2");
45 expect(next.terminalGroups).toEqual([
46 { id: "group-default", terminalIds: ["default"] },
47 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
48 ]);
49 });
50
51 it("resets to default when closing the last terminal", () => {
52 const closed = closeThreadTerminal(createDefaultThreadTerminalState(), "default");
53
54 expect(closed).toEqual(createDefaultThreadTerminalState());
55 });
56
57 it("keeps a valid active terminal after closing an active split terminal", () => {
58 const splitOnce = splitThreadTerminal(createDefaultThreadTerminalState(), "terminal-2");
59 const splitTwice = splitThreadTerminal(splitOnce, "terminal-3");
60
61 const closed = closeThreadTerminal(splitTwice, "terminal-3");
62
63 expect(closed.activeTerminalId).toBe("terminal-2");
64 expect(closed.terminalIds).toEqual(["default", "terminal-2"]);
65 expect(closed.terminalGroups).toEqual([
66 { id: "group-default", terminalIds: ["default", "terminal-2"] },
67 ]);
68 });
69});
apps/web/src/threadTerminalState.ts deleted-428
...@@ -1,428 +0,0 @@
1/**
2 * Shared thread terminal state utilities used by the terminal Zustand store.
3 */
4
5import {
6 DEFAULT_THREAD_TERMINAL_HEIGHT,
7 DEFAULT_THREAD_TERMINAL_ID,
8 MAX_THREAD_TERMINAL_COUNT,
9 type ThreadTerminalGroup,
10} from "./types";
11
12/** Terminal slice shared by Thread and draft thread. Same shape in both stores. */
13export interface ThreadTerminalState {
14 terminalOpen: boolean;
15 terminalHeight: number;
16 terminalIds: string[];
17 runningTerminalIds: string[];
18 activeTerminalId: string;
19 terminalGroups: ThreadTerminalGroup[];
20 activeTerminalGroupId: string;
21}
22
23// ─── Helpers shared by terminal updaters ───────────────────────────────────
24
25export function normalizeTerminalIds(terminalIds: string[]): string[] {
26 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice(
27 0,
28 MAX_THREAD_TERMINAL_COUNT,
29 );
30 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
31}
32
33export function normalizeRunningTerminalIds(
34 runningTerminalIds: string[],
35 terminalIds: string[],
36): string[] {
37 if (runningTerminalIds.length === 0) return [];
38 const validTerminalIdSet = new Set(terminalIds);
39 return [...new Set(runningTerminalIds)]
40 .map((id) => id.trim())
41 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
42}
43
44export function fallbackGroupId(terminalId: string): string {
45 return `group-${terminalId}`;
46}
47
48function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string {
49 let candidate = baseId;
50 let index = 2;
51 while (usedGroupIds.has(candidate)) {
52 candidate = `${baseId}-${index}`;
53 index += 1;
54 }
55 usedGroupIds.add(candidate);
56 return candidate;
57}
58
59export function findGroupIndexByTerminalId(
60 terminalGroups: ThreadTerminalGroup[],
61 terminalId: string,
62): number {
63 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
64}
65
66function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
67 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
68}
69
70/** Normalize groups to match terminalIds; used by both thread and draft. */
71export function normalizeTerminalGroups(
72 terminalGroups: ThreadTerminalGroup[],
73 terminalIds: string[],
74): ThreadTerminalGroup[] {
75 const validTerminalIdSet = new Set(terminalIds);
76 const assignedTerminalIds = new Set<string>();
77 const nextGroups: ThreadTerminalGroup[] = [];
78 const usedGroupIds = new Set<string>();
79
80 for (const group of terminalGroups) {
81 const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => {
82 if (!validTerminalIdSet.has(terminalId)) return false;
83 if (assignedTerminalIds.has(terminalId)) return false;
84 return true;
85 });
86 if (groupTerminalIds.length === 0) continue;
87 for (const terminalId of groupTerminalIds) {
88 assignedTerminalIds.add(terminalId);
89 }
90 const baseGroupId =
91 group.id.trim().length > 0
92 ? group.id.trim()
93 : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
94 nextGroups.push({
95 id: assignUniqueGroupId(baseGroupId, usedGroupIds),
96 terminalIds: groupTerminalIds,
97 });
98 }
99
100 for (const terminalId of terminalIds) {
101 if (assignedTerminalIds.has(terminalId)) continue;
102 nextGroups.push({
103 id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds),
104 terminalIds: [terminalId],
105 });
106 }
107
108 if (nextGroups.length === 0) {
109 return [
110 {
111 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
112 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
113 },
114 ];
115 }
116
117 return nextGroups;
118}
119
120function arraysEqual(a: string[], b: string[]): boolean {
121 if (a.length !== b.length) return false;
122 for (let index = 0; index < a.length; index += 1) {
123 if (a[index] !== b[index]) return false;
124 }
125 return true;
126}
127
128function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean {
129 if (left.length !== right.length) return false;
130 for (let index = 0; index < left.length; index += 1) {
131 const leftGroup = left[index];
132 const rightGroup = right[index];
133 if (!leftGroup || !rightGroup) return false;
134 if (leftGroup.id !== rightGroup.id) return false;
135 if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false;
136 }
137 return true;
138}
139
140function threadTerminalStateEqual(left: ThreadTerminalState, right: ThreadTerminalState): boolean {
141 return (
142 left.terminalOpen === right.terminalOpen &&
143 left.terminalHeight === right.terminalHeight &&
144 left.activeTerminalId === right.activeTerminalId &&
145 left.activeTerminalGroupId === right.activeTerminalGroupId &&
146 arraysEqual(left.terminalIds, right.terminalIds) &&
147 arraysEqual(left.runningTerminalIds, right.runningTerminalIds) &&
148 terminalGroupsEqual(left.terminalGroups, right.terminalGroups)
149 );
150}
151
152// ─── Default & normalize ─────────────────────────────────────────────────
153
154const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({
155 terminalOpen: false,
156 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
157 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
158 runningTerminalIds: [],
159 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
160 terminalGroups: [
161 {
162 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
163 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
164 },
165 ],
166 activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
167});
168
169export function createDefaultThreadTerminalState(): ThreadTerminalState {
170 return { ...DEFAULT_THREAD_TERMINAL_STATE };
171}
172
173export function getDefaultThreadTerminalState(): ThreadTerminalState {
174 return DEFAULT_THREAD_TERMINAL_STATE;
175}
176
177export function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState {
178 const terminalIds = normalizeTerminalIds(state.terminalIds);
179 const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID];
180 const runningTerminalIds = normalizeRunningTerminalIds(
181 state.runningTerminalIds,
182 nextTerminalIds,
183 );
184 const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId)
185 ? state.activeTerminalId
186 : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
187 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);
188 const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId)
189 ? state.activeTerminalGroupId
190 : null;
191 const activeGroupIdFromTerminal =
192 terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null;
193
194 const normalized: ThreadTerminalState = {
195 terminalOpen: state.terminalOpen,
196 terminalHeight:
197 Number.isFinite(state.terminalHeight) && state.terminalHeight > 0
198 ? state.terminalHeight
199 : DEFAULT_THREAD_TERMINAL_HEIGHT,
200 terminalIds: nextTerminalIds,
201 runningTerminalIds,
202 activeTerminalId,
203 terminalGroups,
204 activeTerminalGroupId:
205 activeGroupIdFromState ??
206 activeGroupIdFromTerminal ??
207 terminalGroups[0]?.id ??
208 fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
209 };
210 return threadTerminalStateEqual(state, normalized) ? state : normalized;
211}
212
213export function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean {
214 const normalized = normalizeThreadTerminalState(state);
215 return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE);
216}
217
218function isValidTerminalId(terminalId: string): boolean {
219 return terminalId.trim().length > 0;
220}
221
222function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
223 return groups.map((group) => ({
224 id: group.id,
225 terminalIds: [...group.terminalIds],
226 }));
227}
228
229function upsertTerminalIntoGroups(
230 state: ThreadTerminalState,
231 terminalId: string,
232 mode: "split" | "new",
233): ThreadTerminalState {
234 const normalized = normalizeThreadTerminalState(state);
235 if (!isValidTerminalId(terminalId)) {
236 return normalized;
237 }
238
239 const isNewTerminal = !normalized.terminalIds.includes(terminalId);
240 if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
241 return normalized;
242 }
243
244 const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds;
245 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);
246
247 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);
248 if (existingGroupIndex >= 0) {
249 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter(
250 (id) => id !== terminalId,
251 );
252 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
253 terminalGroups.splice(existingGroupIndex, 1);
254 }
255 }
256
257 if (mode === "new") {
258 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
259 const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds);
260 terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });
261 return normalizeThreadTerminalState({
262 ...normalized,
263 terminalOpen: true,
264 terminalIds,
265 activeTerminalId: terminalId,
266 terminalGroups,
267 activeTerminalGroupId: nextGroupId,
268 });
269 }
270
271 let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId);
272 if (activeGroupIndex < 0) {
273 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);
274 }
275 if (activeGroupIndex < 0) {
276 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
277 const nextGroupId = assignUniqueGroupId(fallbackGroupId(normalized.activeTerminalId), usedGroupIds);
278 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });
279 activeGroupIndex = terminalGroups.length - 1;
280 }
281
282 const destinationGroup = terminalGroups[activeGroupIndex];
283 if (!destinationGroup) {
284 return normalized;
285 }
286
287 if (!destinationGroup.terminalIds.includes(terminalId)) {
288 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);
289 if (anchorIndex >= 0) {
290 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId);
291 } else {
292 destinationGroup.terminalIds.push(terminalId);
293 }
294 }
295
296 return normalizeThreadTerminalState({
297 ...normalized,
298 terminalOpen: true,
299 terminalIds,
300 activeTerminalId: terminalId,
301 terminalGroups,
302 activeTerminalGroupId: destinationGroup.id,
303 });
304}
305
306// ─── Pure terminal operations ──────────────────────────────────────────────
307
308export function setThreadTerminalOpen(state: ThreadTerminalState, open: boolean): ThreadTerminalState {
309 const normalized = normalizeThreadTerminalState(state);
310 if (normalized.terminalOpen === open) return normalized;
311 return { ...normalized, terminalOpen: open };
312}
313
314export function setThreadTerminalHeight(
315 state: ThreadTerminalState,
316 height: number,
317): ThreadTerminalState {
318 const normalized = normalizeThreadTerminalState(state);
319 if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) {
320 return normalized;
321 }
322 return { ...normalized, terminalHeight: height };
323}
324
325export function splitThreadTerminal(
326 state: ThreadTerminalState,
327 terminalId: string,
328): ThreadTerminalState {
329 return upsertTerminalIntoGroups(state, terminalId, "split");
330}
331
332export function newThreadTerminal(
333 state: ThreadTerminalState,
334 terminalId: string,
335): ThreadTerminalState {
336 return upsertTerminalIntoGroups(state, terminalId, "new");
337}
338
339export function setThreadActiveTerminal(
340 state: ThreadTerminalState,
341 terminalId: string,
342): ThreadTerminalState {
343 const normalized = normalizeThreadTerminalState(state);
344 if (!normalized.terminalIds.includes(terminalId)) {
345 return normalized;
346 }
347 const activeTerminalGroupId =
348 normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ??
349 normalized.activeTerminalGroupId;
350 if (
351 normalized.activeTerminalId === terminalId &&
352 normalized.activeTerminalGroupId === activeTerminalGroupId
353 ) {
354 return normalized;
355 }
356 return {
357 ...normalized,
358 activeTerminalId: terminalId,
359 activeTerminalGroupId,
360 };
361}
362
363export function closeThreadTerminal(
364 state: ThreadTerminalState,
365 terminalId: string,
366): ThreadTerminalState {
367 const normalized = normalizeThreadTerminalState(state);
368 if (!normalized.terminalIds.includes(terminalId)) {
369 return normalized;
370 }
371
372 const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId);
373 if (remainingTerminalIds.length === 0) {
374 return createDefaultThreadTerminalState();
375 }
376
377 const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId);
378 const nextActiveTerminalId =
379 normalized.activeTerminalId === terminalId
380 ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??
381 remainingTerminalIds[0] ??
382 DEFAULT_THREAD_TERMINAL_ID)
383 : normalized.activeTerminalId;
384
385 const terminalGroups = normalized.terminalGroups
386 .map((group) => ({
387 ...group,
388 terminalIds: group.terminalIds.filter((id) => id !== terminalId),
389 }))
390 .filter((group) => group.terminalIds.length > 0);
391
392 const nextActiveTerminalGroupId =
393 terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ??
394 terminalGroups[0]?.id ??
395 fallbackGroupId(nextActiveTerminalId);
396
397 return normalizeThreadTerminalState({
398 terminalOpen: normalized.terminalOpen,
399 terminalHeight: normalized.terminalHeight,
400 terminalIds: remainingTerminalIds,
401 runningTerminalIds: normalized.runningTerminalIds.filter((id) => id !== terminalId),
402 activeTerminalId: nextActiveTerminalId,
403 terminalGroups,
404 activeTerminalGroupId: nextActiveTerminalGroupId,
405 });
406}
407
408export function setThreadTerminalActivity(
409 state: ThreadTerminalState,
410 terminalId: string,
411 hasRunningSubprocess: boolean,
412): ThreadTerminalState {
413 const normalized = normalizeThreadTerminalState(state);
414 if (!normalized.terminalIds.includes(terminalId)) {
415 return normalized;
416 }
417 const alreadyRunning = normalized.runningTerminalIds.includes(terminalId);
418 if (hasRunningSubprocess === alreadyRunning) {
419 return normalized;
420 }
421 const runningTerminalIds = new Set(normalized.runningTerminalIds);
422 if (hasRunningSubprocess) {
423 runningTerminalIds.add(terminalId);
424 } else {
425 runningTerminalIds.delete(terminalId);
426 }
427 return { ...normalized, runningTerminalIds: [...runningTerminalIds] };
428}