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({
4343 envLocked,
4444 onComposerFocusRequest,
4545}: BranchToolbarProps) {
46 const setThreadBranchAction = useStore((state) => state.setThreadBranch);
47 const setThreadErrorAction = useStore((state) => state.setThreadError);
46 const threads = useStore((store) => store.threads);
47 const projects = useStore((store) => store.projects);
48 const setThreadBranchAction = useStore((store) => store.setThreadBranch);
49 const setThreadErrorAction = useStore((store) => store.setError);
4850 const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId));
4951 const serverThread = useStore((state) =>
5052 state.threads.find((thread) => thread.id === threadId),
......@@ -59,6 +61,9 @@ export default function BranchToolbar({
5961 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
6062 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);
6267 const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
6368 const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
6469 const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
......@@ -119,7 +124,7 @@ export default function BranchToolbar({
119124 });
120125 }
121126 if (hasServerThread) {
122 dispatch.setThreadBranch(activeThreadId, syncedBranch, null);
127 setThreadBranchAction(activeThreadId, syncedBranch, null);
123128 return;
124129 }
125130 setDraftThreadContext(threadId, {
......@@ -142,7 +147,7 @@ export default function BranchToolbar({
142147
143148 const setThreadError = (error: string | null) => {
144149 if (!activeThreadId) return;
145 dispatch.setError(activeThreadId, error);
150 setThreadErrorAction(activeThreadId, error);
146151 };
147152
148153 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
......@@ -171,7 +176,7 @@ export default function BranchToolbar({
171176 });
172177 }
173178 if (hasServerThread) {
174 dispatch.setThreadBranch(activeThreadId, branch, worktreePath);
179 setThreadBranchAction(activeThreadId, branch, worktreePath);
175180 return;
176181 }
177182 setDraftThreadContext(threadId, {
apps/web/src/components/ChatView.tsx+32-38
......@@ -124,7 +124,7 @@ import {
124124 useComposerDraftStore,
125125 useComposerThreadDraft,
126126} from "../composerDraftStore";
127import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
127import { useTerminalStateStore } from "../terminalStateStore";
128128import { clamp } from "effect/Number";
129129
130130function formatMessageMeta(createdAt: string, duration: string | null): string {
......@@ -437,17 +437,13 @@ interface ChatViewProps {
437437}
438438
439439export default function ChatView({ threadId }: ChatViewProps) {
440 const markThreadVisited = useStore((state) => state.markThreadVisited);
441 const setThreadTerminalOpen = useStore((state) => state.setThreadTerminalOpen);
442 const splitThreadTerminalAction = useStore((state) => state.splitThreadTerminal);
443 const newThreadTerminalAction = useStore((state) => state.newThreadTerminal);
444 const setThreadActiveTerminal = useStore((state) => state.setThreadActiveTerminal);
445 const closeThreadTerminalAction = useStore((state) => state.closeThreadTerminal);
446 const setThreadErrorAction = useStore((state) => state.setThreadError);
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);
440 const threads = useStore((store) => store.threads);
441 const projects = useStore((store) => store.projects);
442 const runtimeMode = useStore((store) => store.runtimeMode);
443 const markThreadVisited = useStore((store) => store.markThreadVisited);
444 const setStoreThreadError = useStore((store) => store.setError);
445 const setStoreRuntimeMode = useStore((store) => store.setRuntimeMode);
446 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
451447 const { settings } = useAppSettings();
452448 const navigate = useNavigate();
453449 const rawSearch = useSearch({
......@@ -530,9 +526,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
530526 setMessagesScrollElement(element);
531527 }, []);
532528
533 const terminalState = useTerminalStateStore((state) =>
534 selectThreadTerminalState(state.terminalStateByThreadId, threadId),
535 );
529 const terminalState = useTerminalStateStore((state) => state.getTerminalState(threadId));
536530 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
537531 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);
538532 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
......@@ -571,6 +565,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
571565 [removeComposerDraftImage, threadId],
572566 );
573567
568 const serverThread = threads.find((t) => t.id === threadId);
569 const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId);
574570 const localDraftError = localDraftErrorsByThreadId[threadId] ?? null;
575571 const localDraftThread = useMemo(
576572 () =>
......@@ -595,10 +591,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
595591 const activeThreadId = activeThread?.id ?? null;
596592 const activeLatestTurn = activeThread?.latestTurn ?? null;
597593 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
598 const activeProjectId = activeThread?.projectId ?? null;
599 const activeProject = useStore((state) =>
600 activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined,
601 );
594 const activeProject = projects.find((p) => p.id === activeThread?.projectId);
602595
603596 useEffect(() => {
604597 if (!serverThread) {
......@@ -622,7 +615,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
622615 const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN;
623616 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;
624617
625 dispatch.markThreadVisited(activeThread.id);
618 markThreadVisited(activeThread.id);
626619 }, [
627620 activeThread?.id,
628621 activeThread?.lastVisitedAt,
......@@ -983,8 +976,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
983976 const setThreadError = useCallback(
984977 (targetThreadId: ThreadId | null, error: string | null) => {
985978 if (!targetThreadId) return;
986 if (state.threads.some((thread) => thread.id === targetThreadId)) {
987 dispatch.setError(targetThreadId, error);
979 if (threads.some((thread) => thread.id === targetThreadId)) {
980 setStoreThreadError(targetThreadId, error);
988981 return;
989982 }
990983 setLocalDraftErrorsByThreadId((existing) => {
......@@ -997,7 +990,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
997990 };
998991 });
999992 },
1000 [dispatch, state.threads],
993 [setStoreThreadError, threads],
1001994 );
1002995
1003996 const focusComposer = useCallback(() => {
......@@ -1155,7 +1148,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
11551148 activeProject,
11561149 activeThread,
11571150 activeThreadId,
1158 dispatch,
11591151 gitCwd,
11601152 isLocalDraftThread,
11611153 isServerThread,
......@@ -1266,15 +1258,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
12661258 );
12671259
12681260 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
1269 if (mode === state.runtimeMode) return;
1270 dispatch.setRuntimeMode(mode);
1261 if (mode === runtimeMode) return;
1262 setStoreRuntimeMode(mode);
12711263 scheduleComposerFocus();
12721264 const api = readNativeApi();
12731265 if (!api) return;
12741266
1275 const runningThreadIds = useStore
1276 .getState()
1277 .threads
1267 const runningThreadIds = threads
12781268 .filter((thread) => thread.session !== null && thread.session.status !== "closed")
12791269 .map((thread) => thread.id);
12801270
......@@ -1891,7 +1881,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
18911881 const shouldCreateWorktree =
18921882 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
18931883 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.");
18951885 return;
18961886 }
18971887
......@@ -1964,7 +1954,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19641954 });
19651955 // Keep local thread state in sync immediately so terminal drawer opens
19661956 // 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);
19681958 }
19691959 }
19701960
......@@ -2015,7 +2005,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
20152005 setSendPhase("sending-turn");
20162006 const turnAttachments = await turnAttachmentsPromise;
20172007 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";
20192010 await api.orchestration.dispatchCommand({
20202011 type: "thread.turn.start",
20212012 commandId: newCommandId(),
......@@ -2103,13 +2094,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
21032094 createdAt: new Date().toISOString(),
21042095 });
21052096 } 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 );
21072101 } finally {
21082102 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
21092103 }
21102104 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
21112105 },
2112 [activeThreadId, setThreadErrorAction],
2106 [activeThreadId, setStoreThreadError],
21132107 );
21142108
21152109 const onModelSelect = useCallback(
......@@ -3544,12 +3538,12 @@ const OpenInPicker = memo(function OpenInPicker({
35443538 ] satisfies { label: string; Icon: Icon; value: EditorId }[];
35453539 const primaryOption = options.find(({ value }) => value === lastEditor);
35463540
3547 const activeThread = useStore((state) =>
3548 activeThreadId ? state.threads.find((thread) => thread.id === activeThreadId) : undefined,
3541 const activeThread = useStore((store) =>
3542 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
35493543 );
35503544 const activeProjectId = activeThread?.projectId ?? null;
3551 const activeProject = useStore((state) =>
3552 activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined,
3545 const activeProject = useStore((store) =>
3546 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
35533547 );
35543548
35553549 const openInEditor = useCallback(
apps/web/src/components/DiffPanel.tsx+6-7
......@@ -166,15 +166,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
166166 select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
167167 });
168168 const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) });
169 const { state } = useStore();
170169 const activeThreadId = routeThreadId;
171 const activeThread = activeThreadId
172 ? state.threads.find((thread) => thread.id === activeThreadId)
173 : undefined;
170 const activeThread = useStore((store) =>
171 activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined,
172 );
174173 const activeProjectId = activeThread?.projectId ?? null;
175 const activeProject = activeProjectId
176 ? state.projects.find((project) => project.id === activeProjectId)
177 : undefined;
174 const activeProject = useStore((store) =>
175 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
176 );
178177 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
179178 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
180179 useTurnDiffSummaries(activeThread);
apps/web/src/components/Sidebar.tsx+13-13
......@@ -21,7 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/
2121import { serverConfigQueryOptions } from "../lib/serverReactQuery";
2222import { readNativeApi } from "../nativeApi";
2323import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
24import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
24import { useTerminalStateStore } from "../terminalStateStore";
2525import { toastManager } from "./ui/toast";
2626import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
2727import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
......@@ -231,16 +231,16 @@ function ProjectFavicon({ cwd }: { cwd: string }) {
231231}
232232
233233export default function Sidebar() {
234 const projects = useStore((state) => state.projects);
235 const threads = useStore((state) => state.threads);
236 const markThreadUnread = useStore((state) => state.markThreadUnread);
237 const toggleProject = useStore((state) => state.toggleProject);
234 const projects = useStore((store) => store.projects);
235 const threads = useStore((store) => store.threads);
236 const markThreadUnread = useStore((store) => store.markThreadUnread);
237 const toggleProject = useStore((store) => store.toggleProject);
238238 const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft);
239239 const getDraftThreadByProjectId = useComposerDraftStore(
240240 (store) => store.getDraftThreadByProjectId,
241241 );
242242 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
243 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
243 const getTerminalState = useTerminalStateStore((state) => state.getTerminalState);
244244 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
245245 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
246246 const clearProjectDraftThreadId = useComposerDraftStore(
......@@ -445,7 +445,8 @@ export default function Sidebar() {
445445 const api = readNativeApi();
446446
447447 setIsAddingProject(true);
448 if (api) {
448 try {
449 if (!api) return;
449450 const existing = projects.find((project) => project.cwd === cwd);
450451 if (existing) {
451452 focusMostRecentThreadForProject(existing.id);
......@@ -509,7 +510,7 @@ export default function Sidebar() {
509510 position,
510511 );
511512 if (clicked === "mark-unread") {
512 dispatch.markThreadUnread(threadId);
513 markThreadUnread(threadId);
513514 return;
514515 }
515516 if (clicked !== "delete") return;
......@@ -616,9 +617,9 @@ export default function Sidebar() {
616617 clearProjectDraftThreadById,
617618 markThreadUnread,
618619 navigate,
620 projects,
619621 removeWorktreeMutation,
620622 routeThreadId,
621 projects,
622623 threads,
623624 ],
624625 );
......@@ -709,7 +710,7 @@ export default function Sidebar() {
709710 return () => {
710711 window.removeEventListener("keydown", onWindowKeyDown);
711712 };
712 }, [getDraftThread, handleNewThread, keybindings, routeThreadId, projects, threads]);
713 }, [getDraftThread, handleNewThread, keybindings, projects, routeThreadId, threads]);
713714
714715 const onCreateThreadClick = () => {
715716 if (projects.length === 0) {
......@@ -781,7 +782,7 @@ export default function Sidebar() {
781782 open={project.expanded}
782783 onOpenChange={(open) => {
783784 if (open === project.expanded) return;
784 dispatch.toggleProject(project.id);
785 toggleProject(project.id);
785786 }}
786787 >
787788 <SidebarMenuItem>
......@@ -824,8 +825,7 @@ export default function Sidebar() {
824825 );
825826 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
826827 const terminalStatus = terminalStatusFromRunningIds(
827 selectThreadTerminalState(terminalStateByThreadId, thread.id)
828 .runningTerminalIds,
828 getTerminalState(thread.id).runningTerminalIds,
829829 );
830830
831831 return (
apps/web/src/routes/__root.tsx+5-6
......@@ -124,9 +124,8 @@ function errorDetails(error: unknown): string {
124124}
125125
126126function EventRouter() {
127 const syncServerReadModel = useStore((state) => state.syncServerReadModel);
128 const setThreadTerminalActivity = useStore((state) => state.setThreadTerminalActivity);
129 const setProjectExpanded = useStore((state) => state.setProjectExpanded);
127 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
128 const setProjectExpanded = useStore((store) => store.setProjectExpanded);
130129 const queryClient = useQueryClient();
131130 const navigate = useNavigate();
132131 const pathname = useRouterState({ select: (state) => state.location.pathname });
......@@ -150,7 +149,7 @@ function EventRouter() {
150149 const snapshot = await api.orchestration.getSnapshot();
151150 if (disposed) return;
152151 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
153 dispatch.syncServerReadModel(snapshot);
152 syncServerReadModel(snapshot);
154153 if (pending) {
155154 pending = false;
156155 await flushSnapshotSync();
......@@ -205,7 +204,7 @@ function EventRouter() {
205204 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
206205 return;
207206 }
208 dispatch.setProjectExpanded(payload.bootstrapProjectId, true);
207 setProjectExpanded(payload.bootstrapProjectId, true);
209208
210209 if (pathnameRef.current !== "/") {
211210 return;
......@@ -270,7 +269,7 @@ function EventRouter() {
270269 unsubWelcome();
271270 unsubServerConfigUpdated();
272271 };
273 }, [navigate, queryClient, setProjectExpanded, setThreadTerminalActivity, syncServerReadModel]);
272 }, [navigate, queryClient, setProjectExpanded, syncServerReadModel]);
274273
275274 return null;
276275}
apps/web/src/routes/_chat.$threadId.tsx+2-3
......@@ -133,14 +133,13 @@ const DiffPanelInlineSidebar = (props: {
133133};
134134
135135function ChatThreadRouteView() {
136 const { state } = useStore();
137 const threadsHydrated = state.threadsHydrated;
136 const threadsHydrated = useStore((store) => store.threadsHydrated);
138137 const navigate = useNavigate();
139138 const threadId = Route.useParams({
140139 select: (params) => ThreadId.makeUnsafe(params.threadId),
141140 });
142141 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));
144143 const draftThreadExists = useComposerDraftStore(
145144 (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId),
146145 );
apps/web/src/routes/_chat.settings.tsx+3-3
......@@ -48,8 +48,8 @@ const RUNTIME_MODE_OPTIONS = [
4848
4949function SettingsRouteView() {
5050 const { theme, setTheme, resolvedTheme } = useTheme();
51 const runtimeMode = useStore((state) => state.runtimeMode);
52 const setRuntimeMode = useStore((state) => state.setRuntimeMode);
51 const runtimeMode = useStore((store) => store.runtimeMode);
52 const setRuntimeMode = useStore((store) => store.setRuntimeMode);
5353 const { settings, defaults, updateSettings } = useAppSettings();
5454 const serverConfigQuery = useQuery(serverConfigQueryOptions());
5555 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
......@@ -349,7 +349,7 @@ function SettingsRouteView() {
349349 : "border-border bg-background text-muted-foreground hover:bg-accent"
350350 }`}
351351 onClick={() => {
352 dispatch.setRuntimeMode(option.value);
352 setRuntimeMode(option.value);
353353 }}
354354 >
355355 <span className="flex flex-col">
apps/web/src/store.ts+3-23
......@@ -400,7 +400,7 @@ interface AppStore extends AppState {
400400 setRuntimeMode: (mode: RuntimeMode) => void;
401401}
402402
403export const useAppStore = create<AppStore>((set, _get) => ({
403export const useStore = create<AppStore>((set, _get) => ({
404404 ...readPersistedState(),
405405 syncServerReadModel: (readModel) =>
406406 set((state) => syncServerReadModel(state, readModel)),
......@@ -421,31 +421,11 @@ export const useAppStore = create<AppStore>((set, _get) => ({
421421}));
422422
423423// Persist on every state change (only runtimeMode + expandedProjectCwds)
424useAppStore.subscribe((state) => persistState(state));
425
426// ── useStore (state + store API for call sites that want both) ────────────────
424useStore.subscribe((state) => persistState(state));
427425
428426export function StoreProvider({ children }: { children: ReactNode }) {
429427 useEffect(() => {
430 persistState(useAppStore.getState());
428 persistState(useStore.getState());
431429 }, []);
432430 return createElement(Fragment, null, children);
433431}
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 @@
11/**
2 * Single Zustand store for terminal UI state keyed by threadId. Used by both
3 * persisted (server) threads and draft threads; threadId is stable for both.
2 * Single Zustand store for terminal UI state keyed by threadId.
3 *
4 * Terminal transition helpers are intentionally private to keep the public
5 * API constrained to store actions/selectors.
46 */
57
68import type { ThreadId } from "@t3tools/contracts";
79import { create } from "zustand";
810import { createJSONStorage, persist } from "zustand/middleware";
911import {
10 closeThreadTerminal,
11 getDefaultThreadTerminalState,
12 isDefaultThreadTerminalState,
13 newThreadTerminal,
14 setThreadActiveTerminal,
15 setThreadTerminalActivity,
16 setThreadTerminalHeight,
17 setThreadTerminalOpen,
18 splitThreadTerminal,
19 type ThreadTerminalState,
20} from "./threadTerminalState";
21
22export const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
23
24export function selectThreadTerminalState(
12 DEFAULT_THREAD_TERMINAL_HEIGHT,
13 DEFAULT_THREAD_TERMINAL_ID,
14 MAX_THREAD_TERMINAL_COUNT,
15 type ThreadTerminalGroup,
16} from "./types";
17
18interface ThreadTerminalState {
19 terminalOpen: boolean;
20 terminalHeight: number;
21 terminalIds: string[];
22 runningTerminalIds: string[];
23 activeTerminalId: string;
24 terminalGroups: ThreadTerminalGroup[];
25 activeTerminalGroupId: string;
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(
25407 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,
26408 threadId: ThreadId,
27409): ThreadTerminalState {
......@@ -62,6 +444,7 @@ function updateTerminalStateByThreadId(
62444
63445interface TerminalStateStoreState {
64446 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;
447 getTerminalState: (threadId: ThreadId) => ThreadTerminalState;
65448 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;
66449 setTerminalHeight: (threadId: ThreadId, height: number) => void;
67450 splitTerminal: (threadId: ThreadId, terminalId: string) => void;
......@@ -78,7 +461,7 @@ interface TerminalStateStoreState {
78461
79462export const useTerminalStateStore = create<TerminalStateStoreState>()(
80463 persist(
81 (set) => {
464 (set, get) => {
82465 const updateTerminal = (
83466 threadId: ThreadId,
84467 updater: (state: ThreadTerminalState) => ThreadTerminalState,
......@@ -97,8 +480,10 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
97480 };
98481 });
99482 };
483
100484 return {
101485 terminalStateByThreadId: {},
486 getTerminalState: (threadId) => selectThreadTerminalState(get().terminalStateByThreadId, threadId),
102487 setTerminalOpen: (threadId, open) =>
103488 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),
104489 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}