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 {
124124 useComposerDraftStore,
125125 useComposerThreadDraft,
126126} from "../composerDraftStore";
127import { useTerminalStateStore } from "../terminalStateStore";
127import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
128128import { clamp } from "effect/Number";
129129
130130function formatMessageMeta(createdAt: string, duration: string | null): string {
......@@ -530,7 +530,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
530530 setMessagesScrollElement(element);
531531 }, []);
532532
533 const terminalState = useTerminalStateStore((s) => s.getTerminalState(threadId));
533 const terminalState = useTerminalStateStore((state) =>
534 selectThreadTerminalState(state.terminalStateByThreadId, threadId),
535 );
534536 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
535537 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);
536538 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
apps/web/src/components/Sidebar.tsx+4-3
......@@ -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 { useTerminalStateStore } from "../terminalStateStore";
24import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
2525import { toastManager } from "./ui/toast";
2626import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
2727import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
......@@ -240,7 +240,7 @@ export default function Sidebar() {
240240 (store) => store.getDraftThreadByProjectId,
241241 );
242242 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
243 const getTerminalState = useTerminalStateStore((s) => s.getTerminalState);
243 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
244244 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
245245 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
246246 const clearProjectDraftThreadId = useComposerDraftStore(
......@@ -824,7 +824,8 @@ export default function Sidebar() {
824824 );
825825 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
826826 const terminalStatus = terminalStatusFromRunningIds(
827 getTerminalState(thread.id).runningTerminalIds,
827 selectThreadTerminalState(terminalStateByThreadId, thread.id)
828 .runningTerminalIds,
828829 );
829830
830831 return (
apps/web/src/terminalStateStore.ts+66-45
......@@ -7,34 +7,61 @@ import type { ThreadId } from "@t3tools/contracts";
77import { create } from "zustand";
88import { createJSONStorage, persist } from "zustand/middleware";
99import {
10 createDefaultThreadTerminalState,
10 closeThreadTerminal,
1111 getDefaultThreadTerminalState,
12 reduceThreadTerminalState,
13 type ThreadTerminalAction,
12 isDefaultThreadTerminalState,
13 newThreadTerminal,
14 setThreadActiveTerminal,
15 setThreadTerminalActivity,
16 setThreadTerminalHeight,
17 setThreadTerminalOpen,
18 splitThreadTerminal,
1419 type ThreadTerminalState,
1520} from "./threadTerminalState";
1621
1722export const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
1823
19function applyTerminalAction(
20 state: Record<ThreadId, ThreadTerminalState>,
24export function selectThreadTerminalState(
25 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>,
2126 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,
2338): Record<ThreadId, ThreadTerminalState> {
24 const current = state[threadId] ?? createDefaultThreadTerminalState();
25 const next = reduceThreadTerminalState(current, action);
39 if (threadId.length === 0) {
40 return terminalStateByThreadId;
41 }
42
43 const current = selectThreadTerminalState(terminalStateByThreadId, threadId);
44 const next = updater(current);
2645 if (next === current) {
27 return state;
46 return terminalStateByThreadId;
2847 }
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
2957 return {
30 ...state,
58 ...terminalStateByThreadId,
3159 [threadId]: next,
3260 };
3361}
3462
3563interface TerminalStateStoreState {
3664 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;
37 getTerminalState: (threadId: ThreadId) => ThreadTerminalState;
3865 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;
3966 setTerminalHeight: (threadId: ThreadId, height: number) => void;
4067 splitTerminal: (threadId: ThreadId, terminalId: string) => void;
......@@ -51,51 +78,45 @@ interface TerminalStateStoreState {
5178
5279export const useTerminalStateStore = create<TerminalStateStoreState>()(
5380 persist(
54 (set, get) => {
55 const updateTerminal = (threadId: ThreadId, action: ThreadTerminalAction) => {
56 if (threadId.length === 0) return;
57 set((state) => ({
58 terminalStateByThreadId: applyTerminalAction(
81 (set) => {
82 const updateTerminal = (
83 threadId: ThreadId,
84 updater: (state: ThreadTerminalState) => ThreadTerminalState,
85 ) => {
86 set((state) => {
87 const nextTerminalStateByThreadId = updateTerminalStateByThreadId(
5988 state.terminalStateByThreadId,
6089 threadId,
61 action,
62 ),
63 }));
90 updater,
91 );
92 if (nextTerminalStateByThreadId === state.terminalStateByThreadId) {
93 return state;
94 }
95 return {
96 terminalStateByThreadId: nextTerminalStateByThreadId,
97 };
98 });
6499 };
65100 return {
66101 terminalStateByThreadId: {},
67 getTerminalState: (threadId) => {
68 if (threadId.length === 0) {
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 }),
102 setTerminalOpen: (threadId, open) =>
103 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),
75104 setTerminalHeight: (threadId, height) =>
76 updateTerminal(threadId, { type: "set-height", height }),
105 updateTerminal(threadId, (state) => setThreadTerminalHeight(state, height)),
77106 splitTerminal: (threadId, terminalId) =>
78 updateTerminal(threadId, { type: "split", terminalId }),
107 updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)),
79108 newTerminal: (threadId, terminalId) =>
80 updateTerminal(threadId, { type: "new", terminalId }),
109 updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)),
81110 setActiveTerminal: (threadId, terminalId) =>
82 updateTerminal(threadId, { type: "set-active", terminalId }),
111 updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)),
83112 closeTerminal: (threadId, terminalId) =>
84 updateTerminal(threadId, { type: "close", terminalId }),
113 updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)),
85114 setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>
86 updateTerminal(threadId, { type: "set-activity", terminalId, hasRunningSubprocess }),
87 clearTerminalState: (threadId) => {
88 if (threadId.length === 0) return;
89 set((state) => {
90 if (state.terminalStateByThreadId[threadId] === undefined) {
91 return state;
92 }
93 const { [threadId]: _removed, ...rest } = state.terminalStateByThreadId;
94 return {
95 terminalStateByThreadId: rest as Record<ThreadId, ThreadTerminalState>,
96 };
97 });
98 },
115 updateTerminal(threadId, (state) =>
116 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),
117 ),
118 clearTerminalState: (threadId) =>
119 updateTerminal(threadId, () => getDefaultThreadTerminalState()),
99120 };
100121 },
101122 {
apps/web/src/threadTerminalState.test.ts+11-29
......@@ -1,8 +1,11 @@
11import { describe, expect, it } from "vitest";
22
33import {
4 closeThreadTerminal,
45 createDefaultThreadTerminalState,
5 reduceThreadTerminalState,
6 newThreadTerminal,
7 setThreadTerminalOpen,
8 splitThreadTerminal,
69} from "./threadTerminalState";
710
811describe("threadTerminalState", () => {
......@@ -19,14 +22,8 @@ describe("threadTerminalState", () => {
1922 });
2023
2124 it("opens and splits terminal tabs into the active group", () => {
22 const opened = reduceThreadTerminalState(createDefaultThreadTerminalState(), {
23 type: "set-open",
24 open: true,
25 });
26 const split = reduceThreadTerminalState(opened, {
27 type: "split",
28 terminalId: "terminal-2",
29 });
25 const opened = setThreadTerminalOpen(createDefaultThreadTerminalState(), true);
26 const split = splitThreadTerminal(opened, "terminal-2");
3027
3128 expect(split.terminalOpen).toBe(true);
3229 expect(split.terminalIds).toEqual(["default", "terminal-2"]);
......@@ -40,10 +37,7 @@ describe("threadTerminalState", () => {
4037 });
4138
4239 it("creates new terminals in a new group", () => {
43 const next = reduceThreadTerminalState(createDefaultThreadTerminalState(), {
44 type: "new",
45 terminalId: "terminal-2",
46 });
40 const next = newThreadTerminal(createDefaultThreadTerminalState(), "terminal-2");
4741
4842 expect(next.terminalIds).toEqual(["default", "terminal-2"]);
4943 expect(next.activeTerminalId).toBe("terminal-2");
......@@ -55,28 +49,16 @@ describe("threadTerminalState", () => {
5549 });
5650
5751 it("resets to default when closing the last terminal", () => {
58 const closed = reduceThreadTerminalState(createDefaultThreadTerminalState(), {
59 type: "close",
60 terminalId: "default",
61 });
52 const closed = closeThreadTerminal(createDefaultThreadTerminalState(), "default");
6253
6354 expect(closed).toEqual(createDefaultThreadTerminalState());
6455 });
6556
6657 it("keeps a valid active terminal after closing an active split terminal", () => {
67 const splitOnce = reduceThreadTerminalState(createDefaultThreadTerminalState(), {
68 type: "split",
69 terminalId: "terminal-2",
70 });
71 const splitTwice = reduceThreadTerminalState(splitOnce, {
72 type: "split",
73 terminalId: "terminal-3",
74 });
58 const splitOnce = splitThreadTerminal(createDefaultThreadTerminalState(), "terminal-2");
59 const splitTwice = splitThreadTerminal(splitOnce, "terminal-3");
7560
76 const closed = reduceThreadTerminalState(splitTwice, {
77 type: "close",
78 terminalId: "terminal-3",
79 });
61 const closed = closeThreadTerminal(splitTwice, "terminal-3");
8062
8163 expect(closed.activeTerminalId).toBe("terminal-2");
8264 expect(closed.terminalIds).toEqual(["default", "terminal-2"]);
apps/web/src/threadTerminalState.ts+186-132
......@@ -1,8 +1,5 @@
11/**
2 * Shared thread terminal state and reducer. Used by both the main app 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.
2 * Shared thread terminal state utilities used by the terminal Zustand store.
63 */
74
85import {
......@@ -23,16 +20,7 @@ export interface ThreadTerminalState {
2320 activeTerminalGroupId: string;
2421}
2522
26export type ThreadTerminalAction =
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) ───
23// ─── Helpers shared by terminal updaters ───────────────────────────────────
3624
3725export function normalizeTerminalIds(terminalIds: string[]): string[] {
3826 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice(
......@@ -129,6 +117,38 @@ export function normalizeTerminalGroups(
129117 return nextGroups;
130118}
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
132152// ─── Default & normalize ─────────────────────────────────────────────────
133153
134154const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({
......@@ -171,7 +191,7 @@ export function normalizeThreadTerminalState(state: ThreadTerminalState): Thread
171191 const activeGroupIdFromTerminal =
172192 terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null;
173193
174 return {
194 const normalized: ThreadTerminalState = {
175195 terminalOpen: state.terminalOpen,
176196 terminalHeight:
177197 Number.isFinite(state.terminalHeight) && state.terminalHeight > 0
......@@ -187,52 +207,16 @@ export function normalizeThreadTerminalState(state: ThreadTerminalState): Thread
187207 terminalGroups[0]?.id ??
188208 fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
189209 };
210 return threadTerminalStateEqual(state, normalized) ? state : normalized;
190211}
191212
192// ─── Close terminal (shared) ─────────────────────────────────────────────
193
194function closeTerminal(
195 state: ThreadTerminalState,
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);
213export function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean {
214 const normalized = normalizeThreadTerminalState(state);
215 return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE);
216}
226217
227 return normalizeThreadTerminalState({
228 terminalOpen: state.terminalOpen,
229 terminalHeight: state.terminalHeight,
230 terminalIds: remainingTerminalIds,
231 runningTerminalIds: state.runningTerminalIds.filter((id) => id !== terminalId),
232 activeTerminalId: nextActiveTerminalId,
233 terminalGroups,
234 activeTerminalGroupId: nextActiveTerminalGroupId,
235 });
218function isValidTerminalId(terminalId: string): boolean {
219 return terminalId.trim().length > 0;
236220}
237221
238222function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
......@@ -242,109 +226,55 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[
242226 }));
243227}
244228
245// ─── Single reducer for both draft and persisted thread ────────────────────
246
247export function reduceThreadTerminalState(
229function upsertTerminalIntoGroups(
248230 state: ThreadTerminalState,
249 action: ThreadTerminalAction,
231 terminalId: string,
232 mode: "split" | "new",
250233): ThreadTerminalState {
251234 const normalized = normalizeThreadTerminalState(state);
252
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) {
235 if (!isValidTerminalId(terminalId)) {
301236 return normalized;
302237 }
303238
304 const isNewTerminal = !normalized.terminalIds.includes(action.terminalId);
239 const isNewTerminal = !normalized.terminalIds.includes(terminalId);
305240 if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
306241 return normalized;
307242 }
308243
309 const terminalIds = isNewTerminal
310 ? [...normalized.terminalIds, action.terminalId]
311 : normalized.terminalIds;
244 const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds;
312245 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);
313 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId);
246
247 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);
314248 if (existingGroupIndex >= 0) {
315 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[
316 existingGroupIndex
317 ]!.terminalIds.filter((id) => id !== action.terminalId);
249 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter(
250 (id) => id !== terminalId,
251 );
318252 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
319253 terminalGroups.splice(existingGroupIndex, 1);
320254 }
321255 }
322256
323 if (action.type === "new") {
257 if (mode === "new") {
324258 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
325 const nextGroupId = assignUniqueGroupId(fallbackGroupId(action.terminalId), usedGroupIds);
326 terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] });
259 const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds);
260 terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });
327261 return normalizeThreadTerminalState({
328262 ...normalized,
329263 terminalOpen: true,
330264 terminalIds,
331 activeTerminalId: action.terminalId,
265 activeTerminalId: terminalId,
332266 terminalGroups,
333267 activeTerminalGroupId: nextGroupId,
334268 });
335269 }
336270
337 // split
338271 let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId);
339272 if (activeGroupIndex < 0) {
340273 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);
341274 }
342275 if (activeGroupIndex < 0) {
343276 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
344 const nextGroupId = assignUniqueGroupId(
345 fallbackGroupId(normalized.activeTerminalId),
346 usedGroupIds,
347 );
277 const nextGroupId = assignUniqueGroupId(fallbackGroupId(normalized.activeTerminalId), usedGroupIds);
348278 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });
349279 activeGroupIndex = terminalGroups.length - 1;
350280 }
......@@ -354,12 +284,12 @@ export function reduceThreadTerminalState(
354284 return normalized;
355285 }
356286
357 if (!destinationGroup.terminalIds.includes(action.terminalId)) {
287 if (!destinationGroup.terminalIds.includes(terminalId)) {
358288 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);
359289 if (anchorIndex >= 0) {
360 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId);
290 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId);
361291 } else {
362 destinationGroup.terminalIds.push(action.terminalId);
292 destinationGroup.terminalIds.push(terminalId);
363293 }
364294 }
365295
......@@ -367,8 +297,132 @@ export function reduceThreadTerminalState(
367297 ...normalized,
368298 terminalOpen: true,
369299 terminalIds,
370 activeTerminalId: action.terminalId,
300 activeTerminalId: terminalId,
371301 terminalGroups,
372302 activeTerminalGroupId: destinationGroup.id,
373303 });
374304}
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}