authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 10:45:21-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:13:19-08:00
log246624ad3cce74f0fc14e8b98eb23b2fc1db585c
tree829d966fa7f896c107349a31230ae57eed75bc9e
parent410444aead745b3a27e5c645643c74a3cb3479c3

Refactor terminal state store to use granular thread selectors

- Replace `getTerminalState` reads with `selectThreadTerminalState` in ChatView and Sidebar - Split terminal reducer into pure thread terminal operations used by the Zustand store - Drop persisted default thread entries and add tests for the new operation helpers

5 files changed, 271 insertions(+), 211 deletions(-)

apps/web/src/components/ChatView.tsx+4-2
...@@ -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 { useTerminalStateStore } from "../terminalStateStore";127import { selectThreadTerminalState, 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 {
...@@ -530,7 +530,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -530,7 +530,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
530 setMessagesScrollElement(element);530 setMessagesScrollElement(element);
531 }, []);531 }, []);
532532
533 const terminalState = useTerminalStateStore((s) => s.getTerminalState(threadId));533 const terminalState = useTerminalStateStore((state) =>
534 selectThreadTerminalState(state.terminalStateByThreadId, threadId),
535 );
534 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);536 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
535 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);537 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);
536 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);538 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
apps/web/src/components/Sidebar.tsx+4-3
...@@ -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 { useTerminalStateStore } from "../terminalStateStore";24import { selectThreadTerminalState, 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";
...@@ -240,7 +240,7 @@ export default function Sidebar() {...@@ -240,7 +240,7 @@ export default function Sidebar() {
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 getTerminalState = useTerminalStateStore((s) => s.getTerminalState);243 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
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(
...@@ -824,7 +824,8 @@ export default function Sidebar() {...@@ -824,7 +824,8 @@ export default function Sidebar() {
824 );824 );
825 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);825 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
826 const terminalStatus = terminalStatusFromRunningIds(826 const terminalStatus = terminalStatusFromRunningIds(
827 getTerminalState(thread.id).runningTerminalIds,827 selectThreadTerminalState(terminalStateByThreadId, thread.id)
828 .runningTerminalIds,
828 );829 );
829830
830 return (831 return (
apps/web/src/terminalStateStore.ts+66-45
...@@ -7,34 +7,61 @@ import type { ThreadId } from "@t3tools/contracts";...@@ -7,34 +7,61 @@ import type { ThreadId } from "@t3tools/contracts";
7import { create } from "zustand";7import { create } from "zustand";
8import { createJSONStorage, persist } from "zustand/middleware";8import { createJSONStorage, persist } from "zustand/middleware";
9import {9import {
10 createDefaultThreadTerminalState,10 closeThreadTerminal,
11 getDefaultThreadTerminalState,11 getDefaultThreadTerminalState,
12 reduceThreadTerminalState,12 isDefaultThreadTerminalState,
13 type ThreadTerminalAction,13 newThreadTerminal,
14 setThreadActiveTerminal,
15 setThreadTerminalActivity,
16 setThreadTerminalHeight,
17 setThreadTerminalOpen,
18 splitThreadTerminal,
14 type ThreadTerminalState,19 type ThreadTerminalState,
15} from "./threadTerminalState";20} from "./threadTerminalState";
1621
17export const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";22export const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
1823
19function applyTerminalAction(24export function selectThreadTerminalState(
20 state: Record<ThreadId, ThreadTerminalState>,25 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,
21 threadId: ThreadId,26 threadId: ThreadId,
22 action: ThreadTerminalAction,27): ThreadTerminalState {
28 if (threadId.length === 0) {
29 return getDefaultThreadTerminalState();
30 }
31 return terminalStateByThreadId[threadId] ?? getDefaultThreadTerminalState();
32}
33
34function updateTerminalStateByThreadId(
35 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,
36 threadId: ThreadId,
37 updater: (state: ThreadTerminalState) => ThreadTerminalState,
23): Record<ThreadId, ThreadTerminalState> {38): Record<ThreadId, ThreadTerminalState> {
24 const current = state[threadId] ?? createDefaultThreadTerminalState();39 if (threadId.length === 0) {
25 const next = reduceThreadTerminalState(current, action);40 return terminalStateByThreadId;
41 }
42
43 const current = selectThreadTerminalState(terminalStateByThreadId, threadId);
44 const next = updater(current);
26 if (next === current) {45 if (next === current) {
27 return state;46 return terminalStateByThreadId;
28 }47 }
48
49 if (isDefaultThreadTerminalState(next)) {
50 if (terminalStateByThreadId[threadId] === undefined) {
51 return terminalStateByThreadId;
52 }
53 const { [threadId]: _removed, ...rest } = terminalStateByThreadId;
54 return rest as Record<ThreadId, ThreadTerminalState>;
55 }
56
29 return {57 return {
30 ...state,58 ...terminalStateByThreadId,
31 [threadId]: next,59 [threadId]: next,
32 };60 };
33}61}
3462
35interface TerminalStateStoreState {63interface TerminalStateStoreState {
36 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;64 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;
37 getTerminalState: (threadId: ThreadId) => ThreadTerminalState;
38 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;65 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;
39 setTerminalHeight: (threadId: ThreadId, height: number) => void;66 setTerminalHeight: (threadId: ThreadId, height: number) => void;
40 splitTerminal: (threadId: ThreadId, terminalId: string) => void;67 splitTerminal: (threadId: ThreadId, terminalId: string) => void;
...@@ -51,51 +78,45 @@ interface TerminalStateStoreState {...@@ -51,51 +78,45 @@ interface TerminalStateStoreState {
5178
52export const useTerminalStateStore = create<TerminalStateStoreState>()(79export const useTerminalStateStore = create<TerminalStateStoreState>()(
53 persist(80 persist(
54 (set, get) => {81 (set) => {
55 const updateTerminal = (threadId: ThreadId, action: ThreadTerminalAction) => {82 const updateTerminal = (
56 if (threadId.length === 0) return;83 threadId: ThreadId,
57 set((state) => ({84 updater: (state: ThreadTerminalState) => ThreadTerminalState,
58 terminalStateByThreadId: applyTerminalAction(85 ) => {
86 set((state) => {
87 const nextTerminalStateByThreadId = updateTerminalStateByThreadId(
59 state.terminalStateByThreadId,88 state.terminalStateByThreadId,
60 threadId,89 threadId,
61 action,90 updater,
62 ),91 );
63 }));92 if (nextTerminalStateByThreadId === state.terminalStateByThreadId) {
93 return state;
94 }
95 return {
96 terminalStateByThreadId: nextTerminalStateByThreadId,
97 };
98 });
64 };99 };
65 return {100 return {
66 terminalStateByThreadId: {},101 terminalStateByThreadId: {},
67 getTerminalState: (threadId) => {102 setTerminalOpen: (threadId, open) =>
68 if (threadId.length === 0) {103 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),
69 return getDefaultThreadTerminalState();
70 }
71 const state = get().terminalStateByThreadId[threadId];
72 return state ?? getDefaultThreadTerminalState();
73 },
74 setTerminalOpen: (threadId, open) => updateTerminal(threadId, { type: "set-open", open }),
75 setTerminalHeight: (threadId, height) =>104 setTerminalHeight: (threadId, height) =>
76 updateTerminal(threadId, { type: "set-height", height }),105 updateTerminal(threadId, (state) => setThreadTerminalHeight(state, height)),
77 splitTerminal: (threadId, terminalId) =>106 splitTerminal: (threadId, terminalId) =>
78 updateTerminal(threadId, { type: "split", terminalId }),107 updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)),
79 newTerminal: (threadId, terminalId) =>108 newTerminal: (threadId, terminalId) =>
80 updateTerminal(threadId, { type: "new", terminalId }),109 updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)),
81 setActiveTerminal: (threadId, terminalId) =>110 setActiveTerminal: (threadId, terminalId) =>
82 updateTerminal(threadId, { type: "set-active", terminalId }),111 updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)),
83 closeTerminal: (threadId, terminalId) =>112 closeTerminal: (threadId, terminalId) =>
84 updateTerminal(threadId, { type: "close", terminalId }),113 updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)),
85 setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>114 setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>
86 updateTerminal(threadId, { type: "set-activity", terminalId, hasRunningSubprocess }),115 updateTerminal(threadId, (state) =>
87 clearTerminalState: (threadId) => {116 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),
88 if (threadId.length === 0) return;117 ),
89 set((state) => {118 clearTerminalState: (threadId) =>
90 if (state.terminalStateByThreadId[threadId] === undefined) {119 updateTerminal(threadId, () => getDefaultThreadTerminalState()),
91 return state;
92 }
93 const { [threadId]: _removed, ...rest } = state.terminalStateByThreadId;
94 return {
95 terminalStateByThreadId: rest as Record<ThreadId, ThreadTerminalState>,
96 };
97 });
98 },
99 };120 };
100 },121 },
101 {122 {
apps/web/src/threadTerminalState.test.ts+11-29
...@@ -1,8 +1,11 @@...@@ -1,8 +1,11 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
4 closeThreadTerminal,
4 createDefaultThreadTerminalState,5 createDefaultThreadTerminalState,
5 reduceThreadTerminalState,6 newThreadTerminal,
7 setThreadTerminalOpen,
8 splitThreadTerminal,
6} from "./threadTerminalState";9} from "./threadTerminalState";
710
8describe("threadTerminalState", () => {11describe("threadTerminalState", () => {
...@@ -19,14 +22,8 @@ describe("threadTerminalState", () => {...@@ -19,14 +22,8 @@ describe("threadTerminalState", () => {
19 });22 });
2023
21 it("opens and splits terminal tabs into the active group", () => {24 it("opens and splits terminal tabs into the active group", () => {
22 const opened = reduceThreadTerminalState(createDefaultThreadTerminalState(), {25 const opened = setThreadTerminalOpen(createDefaultThreadTerminalState(), true);
23 type: "set-open",26 const split = splitThreadTerminal(opened, "terminal-2");
24 open: true,
25 });
26 const split = reduceThreadTerminalState(opened, {
27 type: "split",
28 terminalId: "terminal-2",
29 });
3027
31 expect(split.terminalOpen).toBe(true);28 expect(split.terminalOpen).toBe(true);
32 expect(split.terminalIds).toEqual(["default", "terminal-2"]);29 expect(split.terminalIds).toEqual(["default", "terminal-2"]);
...@@ -40,10 +37,7 @@ describe("threadTerminalState", () => {...@@ -40,10 +37,7 @@ describe("threadTerminalState", () => {
40 });37 });
4138
42 it("creates new terminals in a new group", () => {39 it("creates new terminals in a new group", () => {
43 const next = reduceThreadTerminalState(createDefaultThreadTerminalState(), {40 const next = newThreadTerminal(createDefaultThreadTerminalState(), "terminal-2");
44 type: "new",
45 terminalId: "terminal-2",
46 });
4741
48 expect(next.terminalIds).toEqual(["default", "terminal-2"]);42 expect(next.terminalIds).toEqual(["default", "terminal-2"]);
49 expect(next.activeTerminalId).toBe("terminal-2");43 expect(next.activeTerminalId).toBe("terminal-2");
...@@ -55,28 +49,16 @@ describe("threadTerminalState", () => {...@@ -55,28 +49,16 @@ describe("threadTerminalState", () => {
55 });49 });
5650
57 it("resets to default when closing the last terminal", () => {51 it("resets to default when closing the last terminal", () => {
58 const closed = reduceThreadTerminalState(createDefaultThreadTerminalState(), {52 const closed = closeThreadTerminal(createDefaultThreadTerminalState(), "default");
59 type: "close",
60 terminalId: "default",
61 });
6253
63 expect(closed).toEqual(createDefaultThreadTerminalState());54 expect(closed).toEqual(createDefaultThreadTerminalState());
64 });55 });
6556
66 it("keeps a valid active terminal after closing an active split terminal", () => {57 it("keeps a valid active terminal after closing an active split terminal", () => {
67 const splitOnce = reduceThreadTerminalState(createDefaultThreadTerminalState(), {58 const splitOnce = splitThreadTerminal(createDefaultThreadTerminalState(), "terminal-2");
68 type: "split",59 const splitTwice = splitThreadTerminal(splitOnce, "terminal-3");
69 terminalId: "terminal-2",
70 });
71 const splitTwice = reduceThreadTerminalState(splitOnce, {
72 type: "split",
73 terminalId: "terminal-3",
74 });
7560
76 const closed = reduceThreadTerminalState(splitTwice, {61 const closed = closeThreadTerminal(splitTwice, "terminal-3");
77 type: "close",
78 terminalId: "terminal-3",
79 });
8062
81 expect(closed.activeTerminalId).toBe("terminal-2");63 expect(closed.activeTerminalId).toBe("terminal-2");
82 expect(closed.terminalIds).toEqual(["default", "terminal-2"]);64 expect(closed.terminalIds).toEqual(["default", "terminal-2"]);
apps/web/src/threadTerminalState.ts+186-132
...@@ -1,8 +1,5 @@...@@ -1,8 +1,5 @@
1/**1/**
2 * Shared thread terminal state and reducer. Used by both the main app store2 * Shared thread terminal state utilities used by the terminal Zustand store.
3 * (persisted threads) and the composer draft store (draft threads), so we can
4 * reuse the same shape and helpers and easily copy terminal state from draft
5 * to thread when reconciling server state.
6 */3 */
74
8import {5import {
...@@ -23,16 +20,7 @@ export interface ThreadTerminalState {...@@ -23,16 +20,7 @@ export interface ThreadTerminalState {
23 activeTerminalGroupId: string;20 activeTerminalGroupId: string;
24}21}
2522
26export type ThreadTerminalAction =23// ─── Helpers shared by terminal updaters ───────────────────────────────────
27 | { type: "set-open"; open: boolean }
28 | { type: "set-height"; height: number }
29 | { type: "split"; terminalId: string }
30 | { type: "new"; terminalId: string }
31 | { type: "set-active"; terminalId: string }
32 | { type: "close"; terminalId: string }
33 | { type: "set-activity"; terminalId: string; hasRunningSubprocess: boolean };
34
35// ─── Helpers (exported for store reducers that need to work with Thread) ───
3624
37export function normalizeTerminalIds(terminalIds: string[]): string[] {25export function normalizeTerminalIds(terminalIds: string[]): string[] {
38 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice(26 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice(
...@@ -129,6 +117,38 @@ export function normalizeTerminalGroups(...@@ -129,6 +117,38 @@ export function normalizeTerminalGroups(
129 return nextGroups;117 return nextGroups;
130}118}
131119
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
132// ─── Default & normalize ─────────────────────────────────────────────────152// ─── Default & normalize ─────────────────────────────────────────────────
133153
134const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({154const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({
...@@ -171,7 +191,7 @@ export function normalizeThreadTerminalState(state: ThreadTerminalState): Thread...@@ -171,7 +191,7 @@ export function normalizeThreadTerminalState(state: ThreadTerminalState): Thread
171 const activeGroupIdFromTerminal =191 const activeGroupIdFromTerminal =
172 terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null;192 terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null;
173193
174 return {194 const normalized: ThreadTerminalState = {
175 terminalOpen: state.terminalOpen,195 terminalOpen: state.terminalOpen,
176 terminalHeight:196 terminalHeight:
177 Number.isFinite(state.terminalHeight) && state.terminalHeight > 0197 Number.isFinite(state.terminalHeight) && state.terminalHeight > 0
...@@ -187,52 +207,16 @@ export function normalizeThreadTerminalState(state: ThreadTerminalState): Thread...@@ -187,52 +207,16 @@ export function normalizeThreadTerminalState(state: ThreadTerminalState): Thread
187 terminalGroups[0]?.id ??207 terminalGroups[0]?.id ??
188 fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),208 fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
189 };209 };
210 return threadTerminalStateEqual(state, normalized) ? state : normalized;
190}211}
191212
192// ─── Close terminal (shared) ─────────────────────────────────────────────213export function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean {
193214 const normalized = normalizeThreadTerminalState(state);
194function closeTerminal(215 return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE);
195 state: ThreadTerminalState,216}
196 terminalId: string,
197): ThreadTerminalState {
198 if (!state.terminalIds.includes(terminalId)) {
199 return state;
200 }
201
202 const remainingTerminalIds = state.terminalIds.filter((id) => id !== terminalId);
203 if (remainingTerminalIds.length === 0) {
204 return createDefaultThreadTerminalState();
205 }
206
207 const closedTerminalIndex = state.terminalIds.indexOf(terminalId);
208 const nextActiveTerminalId =
209 state.activeTerminalId === terminalId
210 ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??
211 remainingTerminalIds[0] ??
212 DEFAULT_THREAD_TERMINAL_ID)
213 : state.activeTerminalId;
214
215 const terminalGroups = state.terminalGroups
216 .map((group) => ({
217 ...group,
218 terminalIds: group.terminalIds.filter((id) => id !== terminalId),
219 }))
220 .filter((group) => group.terminalIds.length > 0);
221
222 const nextActiveTerminalGroupId =
223 terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ??
224 terminalGroups[0]?.id ??
225 fallbackGroupId(nextActiveTerminalId);
226217
227 return normalizeThreadTerminalState({218function isValidTerminalId(terminalId: string): boolean {
228 terminalOpen: state.terminalOpen,219 return terminalId.trim().length > 0;
229 terminalHeight: state.terminalHeight,
230 terminalIds: remainingTerminalIds,
231 runningTerminalIds: state.runningTerminalIds.filter((id) => id !== terminalId),
232 activeTerminalId: nextActiveTerminalId,
233 terminalGroups,
234 activeTerminalGroupId: nextActiveTerminalGroupId,
235 });
236}220}
237221
238function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {222function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
...@@ -242,109 +226,55 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[...@@ -242,109 +226,55 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[
242 }));226 }));
243}227}
244228
245// ─── Single reducer for both draft and persisted thread ────────────────────229function upsertTerminalIntoGroups(
246
247export function reduceThreadTerminalState(
248 state: ThreadTerminalState,230 state: ThreadTerminalState,
249 action: ThreadTerminalAction,231 terminalId: string,
232 mode: "split" | "new",
250): ThreadTerminalState {233): ThreadTerminalState {
251 const normalized = normalizeThreadTerminalState(state);234 const normalized = normalizeThreadTerminalState(state);
252235 if (!isValidTerminalId(terminalId)) {
253 if (action.type === "set-open") {
254 if (normalized.terminalOpen === action.open) return normalized;
255 return { ...normalized, terminalOpen: action.open };
256 }
257
258 if (action.type === "set-height") {
259 if (
260 !Number.isFinite(action.height) ||
261 action.height <= 0 ||
262 normalized.terminalHeight === action.height
263 ) {
264 return normalized;
265 }
266 return { ...normalized, terminalHeight: action.height };
267 }
268
269 if (action.type === "set-active") {
270 if (!normalized.terminalIds.includes(action.terminalId)) {
271 return normalized;
272 }
273 const activeTerminalGroupId =
274 normalized.terminalGroups.find((group) => group.terminalIds.includes(action.terminalId))?.id ??
275 normalized.activeTerminalGroupId;
276 return {
277 ...normalized,
278 activeTerminalId: action.terminalId,
279 activeTerminalGroupId,
280 };
281 }
282
283 if (action.type === "close") {
284 return closeTerminal(normalized, action.terminalId);
285 }
286
287 if (action.type === "set-activity") {
288 if (!normalized.terminalIds.includes(action.terminalId)) {
289 return normalized;
290 }
291 const runningTerminalIds = new Set(normalized.runningTerminalIds);
292 if (action.hasRunningSubprocess) {
293 runningTerminalIds.add(action.terminalId);
294 } else {
295 runningTerminalIds.delete(action.terminalId);
296 }
297 return { ...normalized, runningTerminalIds: [...runningTerminalIds] };
298 }
299
300 if (!action.terminalId || action.terminalId.trim().length === 0) {
301 return normalized;236 return normalized;
302 }237 }
303238
304 const isNewTerminal = !normalized.terminalIds.includes(action.terminalId);239 const isNewTerminal = !normalized.terminalIds.includes(terminalId);
305 if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {240 if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
306 return normalized;241 return normalized;
307 }242 }
308243
309 const terminalIds = isNewTerminal244 const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds;
310 ? [...normalized.terminalIds, action.terminalId]
311 : normalized.terminalIds;
312 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);245 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);
313 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId);246
247 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);
314 if (existingGroupIndex >= 0) {248 if (existingGroupIndex >= 0) {
315 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[249 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter(
316 existingGroupIndex250 (id) => id !== terminalId,
317 ]!.terminalIds.filter((id) => id !== action.terminalId);251 );
318 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {252 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
319 terminalGroups.splice(existingGroupIndex, 1);253 terminalGroups.splice(existingGroupIndex, 1);
320 }254 }
321 }255 }
322256
323 if (action.type === "new") {257 if (mode === "new") {
324 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));258 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
325 const nextGroupId = assignUniqueGroupId(fallbackGroupId(action.terminalId), usedGroupIds);259 const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds);
326 terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] });260 terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });
327 return normalizeThreadTerminalState({261 return normalizeThreadTerminalState({
328 ...normalized,262 ...normalized,
329 terminalOpen: true,263 terminalOpen: true,
330 terminalIds,264 terminalIds,
331 activeTerminalId: action.terminalId,265 activeTerminalId: terminalId,
332 terminalGroups,266 terminalGroups,
333 activeTerminalGroupId: nextGroupId,267 activeTerminalGroupId: nextGroupId,
334 });268 });
335 }269 }
336270
337 // split
338 let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId);271 let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId);
339 if (activeGroupIndex < 0) {272 if (activeGroupIndex < 0) {
340 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);273 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);
341 }274 }
342 if (activeGroupIndex < 0) {275 if (activeGroupIndex < 0) {
343 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));276 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
344 const nextGroupId = assignUniqueGroupId(277 const nextGroupId = assignUniqueGroupId(fallbackGroupId(normalized.activeTerminalId), usedGroupIds);
345 fallbackGroupId(normalized.activeTerminalId),
346 usedGroupIds,
347 );
348 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });278 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });
349 activeGroupIndex = terminalGroups.length - 1;279 activeGroupIndex = terminalGroups.length - 1;
350 }280 }
...@@ -354,12 +284,12 @@ export function reduceThreadTerminalState(...@@ -354,12 +284,12 @@ export function reduceThreadTerminalState(
354 return normalized;284 return normalized;
355 }285 }
356286
357 if (!destinationGroup.terminalIds.includes(action.terminalId)) {287 if (!destinationGroup.terminalIds.includes(terminalId)) {
358 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);288 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);
359 if (anchorIndex >= 0) {289 if (anchorIndex >= 0) {
360 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId);290 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId);
361 } else {291 } else {
362 destinationGroup.terminalIds.push(action.terminalId);292 destinationGroup.terminalIds.push(terminalId);
363 }293 }
364 }294 }
365295
...@@ -367,8 +297,132 @@ export function reduceThreadTerminalState(...@@ -367,8 +297,132 @@ export function reduceThreadTerminalState(
367 ...normalized,297 ...normalized,
368 terminalOpen: true,298 terminalOpen: true,
369 terminalIds,299 terminalIds,
370 activeTerminalId: action.terminalId,300 activeTerminalId: terminalId,
371 terminalGroups,301 terminalGroups,
372 activeTerminalGroupId: destinationGroup.id,302 activeTerminalGroupId: destinationGroup.id,
373 });303 });
374}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}