authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 09:49:06-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:13:19-08:00
logad4d12cf94e8b1cbd679372ed2bcf453b531b90f
treea17ab606a745607cc27b6693c04a62b356493ce0
parentf6fd760e4eb9e18dd7b89cf45f478835d20145f8

Unify draft and persisted thread terminal flow

- Add shared threadTerminalState.ts: ThreadTerminalState type, default state, normalize helpers, and single reduceThreadTerminalState() used by both flows - Refactor store.ts to use shared terminal reducer and helpers; terminal actions map to reduceThreadTerminalState(threadTerminalSlice(thread), action) - Make draftThreadTerminalState.ts re-export shared module (same shape/actions) - Add terminalState to composer draft store: getDraftThreadTerminalState, setDraftThreadTerminalAction, clearDraftThreadTerminalState; draft thread shape matches Thread terminal slice - ChatView: move draft terminal from useState into composer draft store; use same actions for draft and persisted; hydrate then clear draft terminal when reconciling server state Made-with: Cursor

5 files changed, 623 insertions(+), 695 deletions(-)

apps/web/src/components/ChatView.tsx+41-97
...@@ -61,11 +61,7 @@ import {...@@ -61,11 +61,7 @@ import {
61 type Thread,61 type Thread,
62 type TurnDiffSummary,62 type TurnDiffSummary,
63} from "../types";63} from "../types";
64import {64import type { ThreadTerminalState } from "../threadTerminalState";
65 createDefaultDraftThreadTerminalState,
66 reduceDraftThreadTerminalState,
67 type DraftThreadTerminalState,
68} from "../draftThreadTerminalState";
69import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";65import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
70import { useTheme } from "../hooks/useTheme";66import { useTheme } from "../hooks/useTheme";
71import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";67import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
...@@ -209,7 +205,7 @@ function buildExpandedImagePreview(...@@ -209,7 +205,7 @@ function buildExpandedImagePreview(
209function buildLocalDraftThread(205function buildLocalDraftThread(
210 threadId: ThreadId,206 threadId: ThreadId,
211 draftThread: DraftThreadState,207 draftThread: DraftThreadState,
212 draftTerminalState: DraftThreadTerminalState,208 draftTerminalState: ThreadTerminalState,
213 fallbackModel: string,209 fallbackModel: string,
214 error: string | null,210 error: string | null,
215): Thread {211): Thread {
...@@ -239,7 +235,7 @@ function buildLocalDraftThread(...@@ -239,7 +235,7 @@ function buildLocalDraftThread(
239 };235 };
240}236}
241237
242function threadTerminalsMatchState(thread: Thread, terminalState: DraftThreadTerminalState): boolean {238function threadTerminalsMatchState(thread: Thread, terminalState: ThreadTerminalState): boolean {
243 if (239 if (
244 thread.terminalOpen !== terminalState.terminalOpen ||240 thread.terminalOpen !== terminalState.terminalOpen ||
245 thread.terminalHeight !== terminalState.terminalHeight ||241 thread.terminalHeight !== terminalState.terminalHeight ||
...@@ -547,9 +543,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -547,9 +543,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
547 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<543 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
548 Record<ThreadId, string | null>544 Record<ThreadId, string | null>
549 >({});545 >({});
550 const [draftTerminalStateByThreadId, setDraftTerminalStateByThreadId] = useState<
551 Record<ThreadId, DraftThreadTerminalState>
552 >({});
553 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");546 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
554 const [isConnecting, _setIsConnecting] = useState(false);547 const [isConnecting, _setIsConnecting] = useState(false);
555 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);548 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
...@@ -585,24 +578,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -585,24 +578,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
585 setMessagesScrollElement(element);578 setMessagesScrollElement(element);
586 }, []);579 }, []);
587580
588 const updateDraftTerminalState = useCallback(581 const setDraftThreadTerminalAction = useComposerDraftStore((s) => s.setDraftThreadTerminalAction);
589 (582 const clearDraftThreadTerminalState = useComposerDraftStore(
590 targetThreadId: ThreadId,583 (s) => s.clearDraftThreadTerminalState,
591 updater: (state: DraftThreadTerminalState) => DraftThreadTerminalState,584 );
592 ) => {585 const localDraftTerminalState = useComposerDraftStore((s) =>
593 setDraftTerminalStateByThreadId((existing) => {586 s.getDraftThreadTerminalState(threadId),
594 const previous = existing[targetThreadId] ?? createDefaultDraftThreadTerminalState();
595 const next = updater(previous);
596 if (next === previous) {
597 return existing;
598 }
599 return {
600 ...existing,
601 [targetThreadId]: next,
602 };
603 });
604 },
605 [],
606 );587 );
607588
608 const setPrompt = useCallback(589 const setPrompt = useCallback(
...@@ -637,14 +618,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -637,14 +618,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
637 );618 );
638619
639 const localDraftError = localDraftErrorsByThreadId[threadId] ?? null;620 const localDraftError = localDraftErrorsByThreadId[threadId] ?? null;
640 const localDraftTerminalState = draftTerminalStateByThreadId[threadId];
641 const localDraftThread = useMemo(621 const localDraftThread = useMemo(
642 () =>622 () =>
643 draftThread623 draftThread
644 ? buildLocalDraftThread(624 ? buildLocalDraftThread(
645 threadId,625 threadId,
646 draftThread,626 draftThread,
647 localDraftTerminalState ?? createDefaultDraftThreadTerminalState(),627 localDraftTerminalState,
648 fallbackDraftProject?.model ?? DEFAULT_MODEL,628 fallbackDraftProject?.model ?? DEFAULT_MODEL,
649 localDraftError,629 localDraftError,
650 )630 )
...@@ -681,25 +661,23 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -681,25 +661,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
681 }, [serverThread, threadId]);661 }, [serverThread, threadId]);
682662
683 useEffect(() => {663 useEffect(() => {
684 if (!serverThread || !localDraftTerminalState) {664 if (!serverThread) {
685 return;665 return;
686 }666 }
687 if (!threadTerminalsMatchState(serverThread, localDraftTerminalState)) {667 const draft = useComposerDraftStore.getState().draftThreadsByThreadId[threadId];
668 const draftTerminal = draft?.terminalState;
669 if (draftTerminal === undefined) {
670 return;
671 }
672 if (!threadTerminalsMatchState(serverThread, draftTerminal)) {
688 dispatch({673 dispatch({
689 type: "HYDRATE_THREAD_TERMINALS",674 type: "HYDRATE_THREAD_TERMINALS",
690 threadId: serverThread.id,675 threadId: serverThread.id,
691 terminalState: localDraftTerminalState,676 terminalState: draftTerminal,
692 });677 });
693 return;
694 }678 }
695 setDraftTerminalStateByThreadId((existing) => {679 clearDraftThreadTerminalState(threadId);
696 if (!(threadId in existing)) {680 }, [clearDraftThreadTerminalState, dispatch, serverThread, threadId]);
697 return existing;
698 }
699 const { [threadId]: _removed, ...rest } = existing;
700 return rest as Record<ThreadId, DraftThreadTerminalState>;
701 });
702 }, [dispatch, localDraftTerminalState, serverThread, threadId]);
703681
704 useEffect(() => {682 useEffect(() => {
705 if (!activeThread?.id) return;683 if (!activeThread?.id) return;
...@@ -1108,12 +1086,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1108,12 +1086,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1108 (open: boolean) => {1086 (open: boolean) => {
1109 if (!activeThreadId) return;1087 if (!activeThreadId) return;
1110 if (isLocalDraftThread) {1088 if (isLocalDraftThread) {
1111 updateDraftTerminalState(activeThreadId, (draftState) =>1089 setDraftThreadTerminalAction(activeThreadId, { type: "set-open", open });
1112 reduceDraftThreadTerminalState(draftState, {
1113 type: "set-open",
1114 open,
1115 }),
1116 );
1117 return;1090 return;
1118 }1091 }
1119 dispatch({1092 dispatch({
...@@ -1122,18 +1095,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1122,18 +1095,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
1122 open,1095 open,
1123 });1096 });
1124 },1097 },
1125 [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState],1098 [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction],
1126 );1099 );
1127 const setTerminalHeight = useCallback(1100 const setTerminalHeight = useCallback(
1128 (height: number) => {1101 (height: number) => {
1129 if (!activeThreadId) return;1102 if (!activeThreadId) return;
1130 if (isLocalDraftThread) {1103 if (isLocalDraftThread) {
1131 updateDraftTerminalState(activeThreadId, (draftState) =>1104 setDraftThreadTerminalAction(activeThreadId, { type: "set-height", height });
1132 reduceDraftThreadTerminalState(draftState, {
1133 type: "set-height",
1134 height,
1135 }),
1136 );
1137 return;1105 return;
1138 }1106 }
1139 dispatch({1107 dispatch({
...@@ -1142,7 +1110,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1142,7 +1110,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1142 height,1110 height,
1143 });1111 });
1144 },1112 },
1145 [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState],1113 [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction],
1146 );1114 );
1147 const toggleTerminalVisibility = useCallback(() => {1115 const toggleTerminalVisibility = useCallback(() => {
1148 if (!activeThreadId) return;1116 if (!activeThreadId) return;
...@@ -1152,12 +1120,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1152,12 +1120,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1152 if (!activeThreadId || hasReachedTerminalLimit) return;1120 if (!activeThreadId || hasReachedTerminalLimit) return;
1153 const terminalId = `terminal-${crypto.randomUUID()}`;1121 const terminalId = `terminal-${crypto.randomUUID()}`;
1154 if (isLocalDraftThread) {1122 if (isLocalDraftThread) {
1155 updateDraftTerminalState(activeThreadId, (draftState) =>1123 setDraftThreadTerminalAction(activeThreadId, { type: "split", terminalId });
1156 reduceDraftThreadTerminalState(draftState, {
1157 type: "split",
1158 terminalId,
1159 }),
1160 );
1161 } else {1124 } else {
1162 dispatch({1125 dispatch({
1163 type: "SPLIT_THREAD_TERMINAL",1126 type: "SPLIT_THREAD_TERMINAL",
...@@ -1166,17 +1129,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1166,17 +1129,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
1166 });1129 });
1167 }1130 }
1168 setTerminalFocusRequestId((value) => value + 1);1131 setTerminalFocusRequestId((value) => value + 1);
1169 }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]);1132 }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]);
1170 const createNewTerminal = useCallback(() => {1133 const createNewTerminal = useCallback(() => {
1171 if (!activeThreadId || hasReachedTerminalLimit) return;1134 if (!activeThreadId || hasReachedTerminalLimit) return;
1172 const terminalId = `terminal-${crypto.randomUUID()}`;1135 const terminalId = `terminal-${crypto.randomUUID()}`;
1173 if (isLocalDraftThread) {1136 if (isLocalDraftThread) {
1174 updateDraftTerminalState(activeThreadId, (draftState) =>1137 setDraftThreadTerminalAction(activeThreadId, { type: "new", terminalId });
1175 reduceDraftThreadTerminalState(draftState, {
1176 type: "new",
1177 terminalId,
1178 }),
1179 );
1180 } else {1138 } else {
1181 dispatch({1139 dispatch({
1182 type: "NEW_THREAD_TERMINAL",1140 type: "NEW_THREAD_TERMINAL",
...@@ -1185,17 +1143,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1185,17 +1143,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
1185 });1143 });
1186 }1144 }
1187 setTerminalFocusRequestId((value) => value + 1);1145 setTerminalFocusRequestId((value) => value + 1);
1188 }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]);1146 }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]);
1189 const activateTerminal = useCallback(1147 const activateTerminal = useCallback(
1190 (terminalId: string) => {1148 (terminalId: string) => {
1191 if (!activeThreadId) return;1149 if (!activeThreadId) return;
1192 if (isLocalDraftThread) {1150 if (isLocalDraftThread) {
1193 updateDraftTerminalState(activeThreadId, (draftState) =>1151 setDraftThreadTerminalAction(activeThreadId, { type: "set-active", terminalId });
1194 reduceDraftThreadTerminalState(draftState, {
1195 type: "set-active",
1196 terminalId,
1197 }),
1198 );
1199 } else {1152 } else {
1200 dispatch({1153 dispatch({
1201 type: "SET_THREAD_ACTIVE_TERMINAL",1154 type: "SET_THREAD_ACTIVE_TERMINAL",
...@@ -1205,7 +1158,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1205,7 +1158,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1205 }1158 }
1206 setTerminalFocusRequestId((value) => value + 1);1159 setTerminalFocusRequestId((value) => value + 1);
1207 },1160 },
1208 [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState],1161 [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction],
1209 );1162 );
1210 const closeTerminal = useCallback(1163 const closeTerminal = useCallback(
1211 (terminalId: string) => {1164 (terminalId: string) => {
...@@ -1229,12 +1182,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1229,12 +1182,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1229 void fallbackExitWrite();1182 void fallbackExitWrite();
1230 }1183 }
1231 if (isLocalDraftThread) {1184 if (isLocalDraftThread) {
1232 updateDraftTerminalState(activeThreadId, (draftState) =>1185 setDraftThreadTerminalAction(activeThreadId, { type: "close", terminalId });
1233 reduceDraftThreadTerminalState(draftState, {
1234 type: "close",
1235 terminalId,
1236 }),
1237 );
1238 } else {1186 } else {
1239 dispatch({1187 dispatch({
1240 type: "CLOSE_THREAD_TERMINAL",1188 type: "CLOSE_THREAD_TERMINAL",
...@@ -1244,7 +1192,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1244,7 +1192,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1244 }1192 }
1245 setTerminalFocusRequestId((value) => value + 1);1193 setTerminalFocusRequestId((value) => value + 1);
1246 },1194 },
1247 [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState],1195 [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction],
1248 );1196 );
1249 const runProjectScript = useCallback(1197 const runProjectScript = useCallback(
1250 async (1198 async (
...@@ -1281,12 +1229,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1281,12 +1229,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
1281 setTerminalOpen(true);1229 setTerminalOpen(true);
1282 if (shouldCreateNewTerminal) {1230 if (shouldCreateNewTerminal) {
1283 if (isLocalDraftThread) {1231 if (isLocalDraftThread) {
1284 updateDraftTerminalState(activeThreadId, (draftState) =>1232 setDraftThreadTerminalAction(activeThreadId, {
1285 reduceDraftThreadTerminalState(draftState, {1233 type: "new",
1286 type: "new",1234 terminalId: targetTerminalId,
1287 terminalId: targetTerminalId,1235 });
1288 }),
1289 );
1290 } else {1236 } else {
1291 dispatch({1237 dispatch({
1292 type: "NEW_THREAD_TERMINAL",1238 type: "NEW_THREAD_TERMINAL",
...@@ -1296,12 +1242,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1296,12 +1242,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
1296 }1242 }
1297 } else {1243 } else {
1298 if (isLocalDraftThread) {1244 if (isLocalDraftThread) {
1299 updateDraftTerminalState(activeThreadId, (draftState) =>1245 setDraftThreadTerminalAction(activeThreadId, {
1300 reduceDraftThreadTerminalState(draftState, {1246 type: "set-active",
1301 type: "set-active",1247 terminalId: targetTerminalId,
1302 terminalId: targetTerminalId,1248 });
1303 }),
1304 );
1305 } else {1249 } else {
1306 dispatch({1250 dispatch({
1307 type: "SET_THREAD_ACTIVE_TERMINAL",1251 type: "SET_THREAD_ACTIVE_TERMINAL",
...@@ -1355,7 +1299,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1355,7 +1299,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1355 isServerThread,1299 isServerThread,
1356 setTerminalOpen,1300 setTerminalOpen,
1357 setThreadError,1301 setThreadError,
1358 updateDraftTerminalState,1302 setDraftThreadTerminalAction,
1359 ],1303 ],
1360 );1304 );
1361 const persistProjectScripts = useCallback(1305 const persistProjectScripts = useCallback(
apps/web/src/composerDraftStore.ts+59
...@@ -6,6 +6,13 @@ import {...@@ -6,6 +6,13 @@ import {
6 normalizeModelSlug,6 normalizeModelSlug,
7 type ReasoningEffort,7 type ReasoningEffort,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import {
10 createDefaultThreadTerminalState,
11 getDefaultThreadTerminalState,
12 reduceThreadTerminalState,
13 type ThreadTerminalAction,
14 type ThreadTerminalState,
15} from "./threadTerminalState";
9import type { ChatImageAttachment } from "./types";16import type { ChatImageAttachment } from "./types";
10import { create } from "zustand";17import { create } from "zustand";
11import { createJSONStorage, persist } from "zustand/middleware";18import { createJSONStorage, persist } from "zustand/middleware";
...@@ -64,6 +71,8 @@ export interface DraftThreadState {...@@ -64,6 +71,8 @@ export interface DraftThreadState {
64 branch: string | null;71 branch: string | null;
65 worktreePath: string | null;72 worktreePath: string | null;
66 envMode: DraftThreadEnvMode;73 envMode: DraftThreadEnvMode;
74 /** Terminal layout for draft thread; same shape as Thread terminal slice. Omitted when not set. */
75 terminalState?: ThreadTerminalState;
67}76}
6877
69interface ProjectDraftThread extends DraftThreadState {78interface ProjectDraftThread extends DraftThreadState {
...@@ -113,6 +122,9 @@ interface ComposerDraftStoreState {...@@ -113,6 +122,9 @@ interface ComposerDraftStoreState {
113 ) => void;122 ) => void;
114 clearComposerContent: (threadId: ThreadId) => void;123 clearComposerContent: (threadId: ThreadId) => void;
115 clearThreadDraft: (threadId: ThreadId) => void;124 clearThreadDraft: (threadId: ThreadId) => void;
125 getDraftThreadTerminalState: (threadId: ThreadId) => ThreadTerminalState;
126 setDraftThreadTerminalAction: (threadId: ThreadId, action: ThreadTerminalAction) => void;
127 clearDraftThreadTerminalState: (threadId: ThreadId) => void;
116}128}
117129
118const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {130const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {
...@@ -666,6 +678,53 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -666,6 +678,53 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
666 };678 };
667 });679 });
668 },680 },
681 getDraftThreadTerminalState: (threadId) => {
682 if (threadId.length === 0) {
683 return getDefaultThreadTerminalState();
684 }
685 const draft = get().draftThreadsByThreadId[threadId];
686 return draft?.terminalState ?? getDefaultThreadTerminalState();
687 },
688 setDraftThreadTerminalAction: (threadId, action) => {
689 if (threadId.length === 0) {
690 return;
691 }
692 set((state) => {
693 const existing = state.draftThreadsByThreadId[threadId];
694 if (!existing) {
695 return state;
696 }
697 const current = existing.terminalState ?? createDefaultThreadTerminalState();
698 const next = reduceThreadTerminalState(current, action);
699 if (next === current) {
700 return state;
701 }
702 return {
703 draftThreadsByThreadId: {
704 ...state.draftThreadsByThreadId,
705 [threadId]: { ...existing, terminalState: next },
706 },
707 };
708 });
709 },
710 clearDraftThreadTerminalState: (threadId) => {
711 if (threadId.length === 0) {
712 return;
713 }
714 set((state) => {
715 const existing = state.draftThreadsByThreadId[threadId];
716 if (!existing || existing.terminalState === undefined) {
717 return state;
718 }
719 const { terminalState: _removed, ...rest } = existing;
720 return {
721 draftThreadsByThreadId: {
722 ...state.draftThreadsByThreadId,
723 [threadId]: rest,
724 },
725 };
726 });
727 },
669 setPrompt: (threadId, prompt) => {728 setPrompt: (threadId, prompt) => {
670 if (threadId.length === 0) {729 if (threadId.length === 0) {
671 return;730 return;
apps/web/src/draftThreadTerminalState.ts+16-328
...@@ -1,332 +1,20 @@...@@ -1,332 +1,20 @@
1import {1/**
2 DEFAULT_THREAD_TERMINAL_HEIGHT,2 * Re-exports shared thread terminal state and reducer so draft and persisted
3 DEFAULT_THREAD_TERMINAL_ID,3 * flows use the same shape and logic. See threadTerminalState.ts.
4 MAX_THREAD_TERMINAL_COUNT,4 */
5 type ThreadTerminalGroup,
6} from "./types";
7
8export interface DraftThreadTerminalState {
9 terminalOpen: boolean;
10 terminalHeight: number;
11 terminalIds: string[];
12 runningTerminalIds: string[];
13 activeTerminalId: string;
14 terminalGroups: ThreadTerminalGroup[];
15 activeTerminalGroupId: string;
16}
17
18export type DraftThreadTerminalAction =
19 | { type: "set-open"; open: boolean }
20 | { type: "set-height"; height: number }
21 | { type: "split"; terminalId: string }
22 | { type: "new"; terminalId: string }
23 | { type: "set-active"; terminalId: string }
24 | { type: "close"; terminalId: string }
25 | { type: "set-activity"; terminalId: string; hasRunningSubprocess: boolean };
26
27function normalizeTerminalIds(terminalIds: string[]): string[] {
28 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
29}
30
31function fallbackGroupId(terminalId: string): string {
32 return `group-${terminalId}`;
33}
34
35function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string {
36 let candidate = baseId;
37 let index = 2;
38 while (usedGroupIds.has(candidate)) {
39 candidate = `${baseId}-${index}`;
40 index += 1;
41 }
42 usedGroupIds.add(candidate);
43 return candidate;
44}
45
46function findGroupIndexByTerminalId(terminalGroups: ThreadTerminalGroup[], terminalId: string): number {
47 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
48}
49
50function normalizeTerminalGroups(
51 terminalGroups: ThreadTerminalGroup[],
52 terminalIds: string[],
53): ThreadTerminalGroup[] {
54 const validTerminalIdSet = new Set(terminalIds);
55 const assignedTerminalIds = new Set<string>();
56 const nextGroups: ThreadTerminalGroup[] = [];
57 const usedGroupIds = new Set<string>();
58
59 for (const group of terminalGroups) {
60 const groupTerminalIds = normalizeTerminalIds(group.terminalIds).filter((terminalId) => {
61 if (!validTerminalIdSet.has(terminalId)) return false;
62 if (assignedTerminalIds.has(terminalId)) return false;
63 return true;
64 });
65 if (groupTerminalIds.length === 0) {
66 continue;
67 }
68
69 for (const terminalId of groupTerminalIds) {
70 assignedTerminalIds.add(terminalId);
71 }
72
73 nextGroups.push({
74 id: assignUniqueGroupId(group.id, usedGroupIds),
75 terminalIds: groupTerminalIds,
76 });
77 }
78
79 for (const terminalId of terminalIds) {
80 if (assignedTerminalIds.has(terminalId)) continue;
81 nextGroups.push({
82 id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds),
83 terminalIds: [terminalId],
84 });
85 }
86
87 if (nextGroups.length === 0) {
88 return [
89 {
90 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
91 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
92 },
93 ];
94 }
95
96 return nextGroups;
97}
98
99export function createDefaultDraftThreadTerminalState(): DraftThreadTerminalState {
100 return {
101 terminalOpen: false,
102 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
103 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
104 runningTerminalIds: [],
105 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
106 terminalGroups: [
107 {
108 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
109 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
110 },
111 ],
112 activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
113 };
114}
115
116function normalizeDraftThreadTerminals(state: DraftThreadTerminalState): DraftThreadTerminalState {
117 const terminalIds = normalizeTerminalIds(state.terminalIds);
118 const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID];
119 const runningTerminalIds = normalizeTerminalIds(state.runningTerminalIds).filter((terminalId) =>
120 nextTerminalIds.includes(terminalId),
121 );
122 const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId)
123 ? state.activeTerminalId
124 : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
125 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);
126 const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId)
127 ? state.activeTerminalGroupId
128 : null;
129 const activeGroupIdFromTerminal =
130 terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null;
131
132 return {
133 terminalOpen: state.terminalOpen,
134 terminalHeight:
135 Number.isFinite(state.terminalHeight) && state.terminalHeight > 0
136 ? state.terminalHeight
137 : DEFAULT_THREAD_TERMINAL_HEIGHT,
138 terminalIds: nextTerminalIds,
139 runningTerminalIds,
140 activeTerminalId,
141 terminalGroups,
142 activeTerminalGroupId:
143 activeGroupIdFromState ??
144 activeGroupIdFromTerminal ??
145 terminalGroups[0]?.id ??
146 fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
147 };
148}
149
150function closeDraftTerminal(
151 state: DraftThreadTerminalState,
152 terminalId: string,
153): DraftThreadTerminalState {
154 if (!state.terminalIds.includes(terminalId)) {
155 return state;
156 }
1575
158 const remainingTerminalIds = state.terminalIds.filter((id) => id !== terminalId);6import {
159 if (remainingTerminalIds.length === 0) {7 createDefaultThreadTerminalState,
160 return createDefaultDraftThreadTerminalState();8 normalizeThreadTerminalState,
161 }9 reduceThreadTerminalState,
16210 type ThreadTerminalAction,
163 const closedTerminalIndex = state.terminalIds.indexOf(terminalId);11 type ThreadTerminalState,
164 const nextActiveTerminalId =12} from "./threadTerminalState";
165 state.activeTerminalId === terminalId
166 ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??
167 remainingTerminalIds[0] ??
168 DEFAULT_THREAD_TERMINAL_ID)
169 : state.activeTerminalId;
170
171 const terminalGroups = state.terminalGroups
172 .map((group) => ({
173 ...group,
174 terminalIds: group.terminalIds.filter((id) => id !== terminalId),
175 }))
176 .filter((group) => group.terminalIds.length > 0);
177
178 const nextActiveTerminalGroupId =
179 terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ??
180 terminalGroups[0]?.id ??
181 fallbackGroupId(nextActiveTerminalId);
182
183 return normalizeDraftThreadTerminals({
184 terminalOpen: state.terminalOpen,
185 terminalHeight: state.terminalHeight,
186 terminalIds: remainingTerminalIds,
187 runningTerminalIds: state.runningTerminalIds.filter((id) => id !== terminalId),
188 activeTerminalId: nextActiveTerminalId,
189 terminalGroups,
190 activeTerminalGroupId: nextActiveTerminalGroupId,
191 });
192}
193
194function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
195 return groups.map((group) => ({
196 id: group.id,
197 terminalIds: [...group.terminalIds],
198 }));
199}
200
201export function reduceDraftThreadTerminalState(
202 state: DraftThreadTerminalState,
203 action: DraftThreadTerminalAction,
204): DraftThreadTerminalState {
205 const normalized = normalizeDraftThreadTerminals(state);
206
207 if (action.type === "set-open") {
208 if (normalized.terminalOpen === action.open) return normalized;
209 return {
210 ...normalized,
211 terminalOpen: action.open,
212 };
213 }
214
215 if (action.type === "set-height") {
216 if (!Number.isFinite(action.height) || action.height <= 0 || normalized.terminalHeight === action.height) {
217 return normalized;
218 }
219 return {
220 ...normalized,
221 terminalHeight: action.height,
222 };
223 }
224
225 if (action.type === "set-active") {
226 if (!normalized.terminalIds.includes(action.terminalId)) {
227 return normalized;
228 }
229 const activeTerminalGroupId =
230 normalized.terminalGroups.find((group) => group.terminalIds.includes(action.terminalId))?.id ??
231 normalized.activeTerminalGroupId;
232 return {
233 ...normalized,
234 activeTerminalId: action.terminalId,
235 activeTerminalGroupId,
236 };
237 }
238
239 if (action.type === "close") {
240 return closeDraftTerminal(normalized, action.terminalId);
241 }
242
243 if (action.type === "set-activity") {
244 if (!normalized.terminalIds.includes(action.terminalId)) {
245 return normalized;
246 }
247 const runningTerminalIds = new Set(normalized.runningTerminalIds);
248 if (action.hasRunningSubprocess) {
249 runningTerminalIds.add(action.terminalId);
250 } else {
251 runningTerminalIds.delete(action.terminalId);
252 }
253 return {
254 ...normalized,
255 runningTerminalIds: [...runningTerminalIds],
256 };
257 }
258
259 if (!action.terminalId || action.terminalId.trim().length === 0) {
260 return normalized;
261 }
262
263 const isNewTerminal = !normalized.terminalIds.includes(action.terminalId);
264 if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
265 return normalized;
266 }
267
268 const terminalIds = isNewTerminal
269 ? [...normalized.terminalIds, action.terminalId]
270 : normalized.terminalIds;
271 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);
272 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId);
273 if (existingGroupIndex >= 0) {
274 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter(
275 (id) => id !== action.terminalId,
276 );
277 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
278 terminalGroups.splice(existingGroupIndex, 1);
279 }
280 }
281
282 if (action.type === "new") {
283 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
284 const nextGroupId = assignUniqueGroupId(fallbackGroupId(action.terminalId), usedGroupIds);
285 terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] });
286 return normalizeDraftThreadTerminals({
287 ...normalized,
288 terminalOpen: true,
289 terminalIds,
290 activeTerminalId: action.terminalId,
291 terminalGroups,
292 activeTerminalGroupId: nextGroupId,
293 });
294 }
295
296 let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId);
297 if (activeGroupIndex < 0) {
298 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);
299 }
300 if (activeGroupIndex < 0) {
301 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
302 const nextGroupId = assignUniqueGroupId(
303 fallbackGroupId(normalized.activeTerminalId),
304 usedGroupIds,
305 );
306 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });
307 activeGroupIndex = terminalGroups.length - 1;
308 }
30913
310 const destinationGroup = terminalGroups[activeGroupIndex];14export type DraftThreadTerminalState = ThreadTerminalState;
311 if (!destinationGroup) {15export type DraftThreadTerminalAction = ThreadTerminalAction;
312 return normalized;
313 }
31416
315 if (!destinationGroup.terminalIds.includes(action.terminalId)) {17export const createDefaultDraftThreadTerminalState = createDefaultThreadTerminalState;
316 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);18export const reduceDraftThreadTerminalState = reduceThreadTerminalState;
317 if (anchorIndex >= 0) {
318 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId);
319 } else {
320 destinationGroup.terminalIds.push(action.terminalId);
321 }
322 }
32319
324 return normalizeDraftThreadTerminals({20export { normalizeThreadTerminalState };
325 ...normalized,
326 terminalOpen: true,
327 terminalIds,
328 activeTerminalId: action.terminalId,
329 terminalGroups,
330 activeTerminalGroupId: destinationGroup.id,
331 });
332}
apps/web/src/store.ts+133-270
...@@ -10,34 +10,24 @@ import {...@@ -10,34 +10,24 @@ import {
10 resolveModelSlug,10 resolveModelSlug,
11} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import {12import {
13 DEFAULT_THREAD_TERMINAL_HEIGHT,13 createDefaultThreadTerminalState,
14 DEFAULT_THREAD_TERMINAL_ID,14 normalizeThreadTerminalState,
15 reduceThreadTerminalState,
16 type ThreadTerminalState,
17} from "./threadTerminalState";
18import {
15 DEFAULT_RUNTIME_MODE,19 DEFAULT_RUNTIME_MODE,
16 MAX_THREAD_TERMINAL_COUNT,
17 type ChatMessage,20 type ChatMessage,
18 type Project,21 type Project,
19 type RuntimeMode,22 type RuntimeMode,
20 type Thread,23 type Thread,
21 type ThreadTerminalGroup,
22} from "./types";24} from "./types";
2325
24// ── Actions ──────────────────────────────────────────────────────────26// ── Actions ──────────────────────────────────────────────────────────
2527
26type Action =28type Action =
27 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }29 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
28 | {30 | { type: "HYDRATE_THREAD_TERMINALS"; threadId: ThreadId; terminalState: ThreadTerminalState }
29 type: "HYDRATE_THREAD_TERMINALS";
30 threadId: ThreadId;
31 terminalState: {
32 terminalOpen: boolean;
33 terminalHeight: number;
34 terminalIds: string[];
35 runningTerminalIds: string[];
36 activeTerminalId: string;
37 terminalGroups: ThreadTerminalGroup[];
38 activeTerminalGroupId: string;
39 };
40 }
41 | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string }31 | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string }
42 | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId }32 | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId }
43 | { type: "TOGGLE_PROJECT"; projectId: Project["id"] }33 | { type: "TOGGLE_PROJECT"; projectId: Project["id"] }
...@@ -262,210 +252,24 @@ function attachmentPreviewRoutePath(attachmentId: string): string {...@@ -262,210 +252,24 @@ function attachmentPreviewRoutePath(attachmentId: string): string {
262 return `/attachments/${encodeURIComponent(attachmentId)}`;252 return `/attachments/${encodeURIComponent(attachmentId)}`;
263}253}
264254
265function normalizeTerminalIds(terminalIds: string[]): string[] {255function threadTerminalSlice(thread: Thread): ThreadTerminalState {
266 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
267 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);
268 if (unique.length > 0) {
269 return unique;
270 }
271 return [DEFAULT_THREAD_TERMINAL_ID];
272}
273
274function normalizeRunningTerminalIds(
275 runningTerminalIds: string[],
276 terminalIds: string[],
277): string[] {
278 if (runningTerminalIds.length === 0) return [];
279 const validTerminalIdSet = new Set(terminalIds);
280 return [...new Set(runningTerminalIds)]
281 .map((id) => id.trim())
282 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
283}
284
285function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
286 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
287}
288
289function fallbackGroupId(terminalId: string): string {
290 return `group-${terminalId}`;
291}
292
293function assignUniqueGroupId(groupId: string, usedGroupIds: Set<string>): string {
294 if (!usedGroupIds.has(groupId)) {
295 usedGroupIds.add(groupId);
296 return groupId;
297 }
298 let suffix = 2;
299 while (usedGroupIds.has(`${groupId}-${suffix}`)) {
300 suffix += 1;
301 }
302 const uniqueGroupId = `${groupId}-${suffix}`;
303 usedGroupIds.add(uniqueGroupId);
304 return uniqueGroupId;
305}
306
307function normalizeTerminalGroups(thread: Thread, terminalIds: string[]): ThreadTerminalGroup[] {
308 const validTerminalIdSet = new Set(terminalIds);
309 const assignedTerminalIds = new Set<string>();
310 const usedGroupIds = new Set<string>();
311 const groups: ThreadTerminalGroup[] = [];
312
313 for (const group of thread.terminalGroups) {
314 const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => {
315 if (!validTerminalIdSet.has(terminalId)) return false;
316 if (assignedTerminalIds.has(terminalId)) return false;
317 return true;
318 });
319 if (groupTerminalIds.length === 0) continue;
320 for (const terminalId of groupTerminalIds) {
321 assignedTerminalIds.add(terminalId);
322 }
323 const baseGroupId =
324 group.id.trim().length > 0
325 ? group.id.trim()
326 : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
327 groups.push({
328 id: assignUniqueGroupId(baseGroupId, usedGroupIds),
329 terminalIds: groupTerminalIds,
330 });
331 }
332
333 for (const terminalId of terminalIds) {
334 if (assignedTerminalIds.has(terminalId)) continue;
335 groups.push({
336 id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds),
337 terminalIds: [terminalId],
338 });
339 }
340
341 if (groups.length > 0) {
342 return groups;
343 }
344
345 return [
346 {
347 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
348 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
349 },
350 ];
351}
352
353function findGroupIndexByTerminalId(
354 terminalGroups: ThreadTerminalGroup[],
355 terminalId: string,
356): number {
357 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
358}
359
360function normalizeThreadTerminals(thread: Thread): Thread {
361 const terminalIds = normalizeTerminalIds(thread.terminalIds);
362 const activeTerminalId = terminalIds.includes(thread.activeTerminalId)
363 ? thread.activeTerminalId
364 : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
365 const terminalGroups = normalizeTerminalGroups(thread, terminalIds);
366 const activeGroupIndexFromId = terminalGroups.findIndex(
367 (group) => group.id === thread.activeTerminalGroupId,
368 );
369 const activeGroupIndexFromTerminal = findGroupIndexByTerminalId(terminalGroups, activeTerminalId);
370 const activeGroupIndex =
371 activeGroupIndexFromId >= 0
372 ? activeGroupIndexFromId
373 : activeGroupIndexFromTerminal >= 0
374 ? activeGroupIndexFromTerminal
375 : 0;
376 const activeTerminalGroupId =
377 terminalGroups[activeGroupIndex]?.id ??
378 terminalGroups[0]?.id ??
379 fallbackGroupId(activeTerminalId);
380
381 return {256 return {
382 ...thread,257 terminalOpen: thread.terminalOpen,
383 terminalIds,258 terminalHeight: thread.terminalHeight,
384 runningTerminalIds: normalizeRunningTerminalIds(thread.runningTerminalIds, terminalIds),259 terminalIds: thread.terminalIds,
385 activeTerminalId,260 runningTerminalIds: thread.runningTerminalIds,
386 terminalGroups,261 activeTerminalId: thread.activeTerminalId,
387 activeTerminalGroupId,262 terminalGroups: thread.terminalGroups,
263 activeTerminalGroupId: thread.activeTerminalGroupId,
388 };264 };
389}265}
390266
391function closeThreadTerminal(thread: Thread, terminalId: string): Thread {267function applyTerminalState(thread: Thread, terminal: ThreadTerminalState): Thread {
392 if (!thread.terminalIds.includes(terminalId)) {268 return { ...thread, ...terminal };
393 return thread;
394 }
395
396 const remainingTerminalIds = thread.terminalIds.filter((id) => id !== terminalId);
397 if (remainingTerminalIds.length === 0) {
398 const nextTerminalGroupId = fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID);
399 return normalizeThreadTerminals({
400 ...thread,
401 terminalOpen: false,
402 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
403 runningTerminalIds: [],
404 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
405 terminalGroups: [
406 {
407 id: nextTerminalGroupId,
408 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
409 },
410 ],
411 activeTerminalGroupId: nextTerminalGroupId,
412 });
413 }
414
415 const closedTerminalIndex = thread.terminalIds.indexOf(terminalId);
416 const closedTerminalGroup = thread.terminalGroups.find((group) =>
417 group.terminalIds.includes(terminalId),
418 );
419 const closedTerminalGroupIndex = closedTerminalGroup
420 ? closedTerminalGroup.terminalIds.indexOf(terminalId)
421 : -1;
422 const remainingTerminalsInClosedGroup = (closedTerminalGroup?.terminalIds ?? []).filter(
423 (id) => id !== terminalId,
424 );
425 const nextActiveTerminalId =
426 thread.activeTerminalId === terminalId
427 ? (remainingTerminalsInClosedGroup[
428 Math.min(closedTerminalGroupIndex, remainingTerminalsInClosedGroup.length - 1)
429 ] ??
430 remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??
431 remainingTerminalIds[0] ??
432 DEFAULT_THREAD_TERMINAL_ID)
433 : thread.activeTerminalId;
434 const nextTerminalGroups = thread.terminalGroups
435 .map((group) => ({
436 ...group,
437 terminalIds: group.terminalIds.filter((id) => id !== terminalId),
438 }))
439 .filter((group) => group.terminalIds.length > 0);
440
441 return normalizeThreadTerminals({
442 ...thread,
443 terminalIds: remainingTerminalIds,
444 runningTerminalIds: thread.runningTerminalIds.filter((id) => id !== terminalId),
445 activeTerminalId: nextActiveTerminalId,
446 terminalGroups: nextTerminalGroups,
447 });
448}269}
449270
450function setThreadTerminalActivity(271function normalizeThreadTerminals(thread: Thread): Thread {
451 thread: Thread,272 return applyTerminalState(thread, normalizeThreadTerminalState(threadTerminalSlice(thread)));
452 terminalId: string,
453 hasRunningSubprocess: boolean,
454): Thread {
455 const normalizedThread = normalizeThreadTerminals(thread);
456 if (!normalizedThread.terminalIds.includes(terminalId)) {
457 return normalizedThread;
458 }
459 const runningTerminalIds = new Set(normalizedThread.runningTerminalIds);
460 if (hasRunningSubprocess) {
461 runningTerminalIds.add(terminalId);
462 } else {
463 runningTerminalIds.delete(terminalId);
464 }
465 return normalizeThreadTerminals({
466 ...normalizedThread,
467 runningTerminalIds: [...runningTerminalIds],
468 });
469}273}
470274
471function syncServerReadModelState(state: AppState, readModel: OrchestrationReadModel): AppState {275function syncServerReadModelState(state: AppState, readModel: OrchestrationReadModel): AppState {
...@@ -489,10 +293,14 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -489,10 +293,14 @@ export function reducer(state: AppState, action: Action): AppState {
489 const existingThreadById = new Map(293 const existingThreadById = new Map(
490 state.threads.map((thread) => [thread.id, thread] as const),294 state.threads.map((thread) => [thread.id, thread] as const),
491 );295 );
296 const defaultTerminal = createDefaultThreadTerminalState();
492 const threads = action.readModel.threads297 const threads = action.readModel.threads
493 .filter((thread) => thread.deletedAt === null)298 .filter((thread) => thread.deletedAt === null)
494 .map((thread) => {299 .map((thread) => {
495 const existing = existingThreadById.get(thread.id);300 const existing = existingThreadById.get(thread.id);
301 const terminalSlice: ThreadTerminalState = existing
302 ? threadTerminalSlice(existing)
303 : defaultTerminal;
496304
497 return normalizeThreadTerminals({305 return normalizeThreadTerminals({
498 id: thread.id,306 id: thread.id,
...@@ -500,19 +308,7 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -500,19 +308,7 @@ export function reducer(state: AppState, action: Action): AppState {
500 projectId: thread.projectId,308 projectId: thread.projectId,
501 title: thread.title,309 title: thread.title,
502 model: resolveModelSlug(thread.model),310 model: resolveModelSlug(thread.model),
503 terminalOpen: existing?.terminalOpen ?? false,311 ...terminalSlice,
504 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
505 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],
506 runningTerminalIds: existing?.runningTerminalIds ?? [],
507 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,
508 terminalGroups: existing?.terminalGroups ?? [
509 {
510 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
511 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
512 },
513 ],
514 activeTerminalGroupId:
515 existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
516 session: thread.session312 session: thread.session
517 ? {313 ? {
518 sessionId:314 sessionId:
...@@ -578,16 +374,7 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -578,16 +374,7 @@ export function reducer(state: AppState, action: Action): AppState {
578 return {374 return {
579 ...state,375 ...state,
580 threads: updateThread(state.threads, action.threadId, (thread) =>376 threads: updateThread(state.threads, action.threadId, (thread) =>
581 normalizeThreadTerminals({377 applyTerminalState(thread, normalizeThreadTerminalState(action.terminalState)),
582 ...thread,
583 terminalOpen: action.terminalState.terminalOpen,
584 terminalHeight: action.terminalState.terminalHeight,
585 terminalIds: action.terminalState.terminalIds,
586 runningTerminalIds: action.terminalState.runningTerminalIds,
587 activeTerminalId: action.terminalState.activeTerminalId,
588 terminalGroups: action.terminalState.terminalGroups,
589 activeTerminalGroupId: action.terminalState.activeTerminalGroupId,
590 }),
591 ),378 ),
592 };379 };
593380
...@@ -648,7 +435,14 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -648,7 +435,14 @@ export function reducer(state: AppState, action: Action): AppState {
648 return {435 return {
649 ...state,436 ...state,
650 threads: updateThread(state.threads, action.threadId, (thread) =>437 threads: updateThread(state.threads, action.threadId, (thread) =>
651 setThreadTerminalActivity(thread, action.terminalId, action.hasRunningSubprocess),438 applyTerminalState(
439 thread,
440 reduceThreadTerminalState(threadTerminalSlice(thread), {
441 type: "set-activity",
442 terminalId: action.terminalId,
443 hasRunningSubprocess: action.hasRunningSubprocess,
444 }),
445 ),
652 ),446 ),
653 };447 };
654448
...@@ -663,54 +457,123 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -663,54 +457,123 @@ export function reducer(state: AppState, action: Action): AppState {
663 case "TOGGLE_THREAD_TERMINAL":457 case "TOGGLE_THREAD_TERMINAL":
664 return {458 return {
665 ...state,459 ...state,
666 threads: updateThread(state.threads, action.threadId, (t) => ({460 threads: updateThread(state.threads, action.threadId, (thread) =>
667 ...t,461 applyTerminalState(
668 terminalOpen: !t.terminalOpen,462 thread,
669 })),463 reduceThreadTerminalState(threadTerminalSlice(thread), {
464 type: "set-open",
465 open: !thread.terminalOpen,
466 }),
467 ),
468 ),
670 };469 };
671470
672 case "SET_THREAD_TERMINAL_OPEN":471 case "SET_THREAD_TERMINAL_OPEN":
673 return {472 return {
674 ...state,473 ...state,
675 threads: updateThread(state.threads, action.threadId, (t) => ({474 threads: updateThread(state.threads, action.threadId, (thread) =>
676 ...t,475 applyTerminalState(
677 terminalOpen: action.open,476 thread,
678 })),477 reduceThreadTerminalState(threadTerminalSlice(thread), {
478 type: "set-open",
479 open: action.open,
480 }),
481 ),
482 ),
679 };483 };
680484
681 case "SET_THREAD_TERMINAL_HEIGHT":485 case "SET_THREAD_TERMINAL_HEIGHT":
486 return {
487 ...state,
488 threads: updateThread(state.threads, action.threadId, (thread) =>
489 applyTerminalState(
490 thread,
491 reduceThreadTerminalState(threadTerminalSlice(thread), {
492 type: "set-height",
493 height: action.height,
494 }),
495 ),
496 ),
497 };
498
499 case "SPLIT_THREAD_TERMINAL":
500 return {
501 ...state,
502 threads: updateThread(state.threads, action.threadId, (thread) =>
503 applyTerminalState(
504 thread,
505 reduceThreadTerminalState(threadTerminalSlice(thread), {
506 type: "split",
507 terminalId: action.terminalId,
508 }),
509 ),
510 ),
511 };
512
513 case "NEW_THREAD_TERMINAL":
514 return {
515 ...state,
516 threads: updateThread(state.threads, action.threadId, (thread) =>
517 applyTerminalState(
518 thread,
519 reduceThreadTerminalState(threadTerminalSlice(thread), {
520 type: "new",
521 terminalId: action.terminalId,
522 }),
523 ),
524 ),
525 };
526
527 case "SET_THREAD_ACTIVE_TERMINAL":
528 return {
529 ...state,
530 threads: updateThread(state.threads, action.threadId, (thread) =>
531 applyTerminalState(
532 thread,
533 reduceThreadTerminalState(threadTerminalSlice(thread), {
534 type: "set-active",
535 terminalId: action.terminalId,
536 }),
537 ),
538 ),
539 };
540
541 case "CLOSE_THREAD_TERMINAL":
542 return {
543 ...state,
544 threads: updateThread(state.threads, action.threadId, (thread) =>
545 applyTerminalState(
546 thread,
547 reduceThreadTerminalState(threadTerminalSlice(thread), {
548 type: "close",
549 terminalId: action.terminalId,
550 }),
551 ),
552 ),
553 };
554
555 case "SET_ERROR":
682 return {556 return {
683 ...state,557 ...state,
684 threads: updateThread(state.threads, action.threadId, (t) => ({558 threads: updateThread(state.threads, action.threadId, (t) => ({
685 ...t,559 ...t,
686 terminalHeight: action.height,560 error: action.error,
687 })),561 })),
688 };562 };
689563
690 case "SPLIT_THREAD_TERMINAL":564 case "SET_THREAD_BRANCH": {
691 return {565 return {
692 ...state,566 ...state,
693 threads: updateThread(state.threads, action.threadId, (thread) => {567 threads: updateThread(state.threads, action.threadId, (t) => {
694 const normalizedThread = normalizeThreadTerminals(thread);568 // When the effective cwd changes (worktreePath differs), the old
695 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);569 // session is no longer valid — clear it so ensureSession creates a
696 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {570 // new one with the correct cwd on the next message.
697 return normalizedThread;571 const cwdChanged = t.worktreePath !== action.worktreePath;
698 }572 return {
699 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)573 ...t,
700 ? normalizedThread.terminalIds574 branch: action.branch,
701 : [...normalizedThread.terminalIds, action.terminalId];575 worktreePath: action.worktreePath,
702 const terminalGroups = normalizedThread.terminalGroups.map((group) => ({576 ...(cwdChanged ? { session: null } : {}),
703 ...group,
704 terminalIds: [...group.terminalIds],
705 }));
706 const normalizedMessage: ChatMessage = {
707 id: message.id,
708 role: message.role,
709 text: message.text,
710 createdAt: message.createdAt,
711 streaming: message.streaming,
712 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
713 ...(attachments && attachments.length > 0 ? { attachments } : {}),
714 };577 };
715 return normalizedMessage;578 return normalizedMessage;
716 }),579 }),
apps/web/src/threadTerminalState.ts created+374
...@@ -0,0 +1,374 @@
1/**
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.
6 */
7
8import {
9 DEFAULT_THREAD_TERMINAL_HEIGHT,
10 DEFAULT_THREAD_TERMINAL_ID,
11 MAX_THREAD_TERMINAL_COUNT,
12 type ThreadTerminalGroup,
13} from "./types";
14
15/** Terminal slice shared by Thread and draft thread. Same shape in both stores. */
16export interface ThreadTerminalState {
17 terminalOpen: boolean;
18 terminalHeight: number;
19 terminalIds: string[];
20 runningTerminalIds: string[];
21 activeTerminalId: string;
22 terminalGroups: ThreadTerminalGroup[];
23 activeTerminalGroupId: string;
24}
25
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) ───
36
37export function normalizeTerminalIds(terminalIds: string[]): string[] {
38 const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice(
39 0,
40 MAX_THREAD_TERMINAL_COUNT,
41 );
42 return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID];
43}
44
45export function normalizeRunningTerminalIds(
46 runningTerminalIds: string[],
47 terminalIds: string[],
48): string[] {
49 if (runningTerminalIds.length === 0) return [];
50 const validTerminalIdSet = new Set(terminalIds);
51 return [...new Set(runningTerminalIds)]
52 .map((id) => id.trim())
53 .filter((id) => id.length > 0 && validTerminalIdSet.has(id));
54}
55
56export function fallbackGroupId(terminalId: string): string {
57 return `group-${terminalId}`;
58}
59
60function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string {
61 let candidate = baseId;
62 let index = 2;
63 while (usedGroupIds.has(candidate)) {
64 candidate = `${baseId}-${index}`;
65 index += 1;
66 }
67 usedGroupIds.add(candidate);
68 return candidate;
69}
70
71export function findGroupIndexByTerminalId(
72 terminalGroups: ThreadTerminalGroup[],
73 terminalId: string,
74): number {
75 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
76}
77
78function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
79 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
80}
81
82/** Normalize groups to match terminalIds; used by both thread and draft. */
83export function normalizeTerminalGroups(
84 terminalGroups: ThreadTerminalGroup[],
85 terminalIds: string[],
86): ThreadTerminalGroup[] {
87 const validTerminalIdSet = new Set(terminalIds);
88 const assignedTerminalIds = new Set<string>();
89 const nextGroups: ThreadTerminalGroup[] = [];
90 const usedGroupIds = new Set<string>();
91
92 for (const group of terminalGroups) {
93 const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => {
94 if (!validTerminalIdSet.has(terminalId)) return false;
95 if (assignedTerminalIds.has(terminalId)) return false;
96 return true;
97 });
98 if (groupTerminalIds.length === 0) continue;
99 for (const terminalId of groupTerminalIds) {
100 assignedTerminalIds.add(terminalId);
101 }
102 const baseGroupId =
103 group.id.trim().length > 0
104 ? group.id.trim()
105 : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
106 nextGroups.push({
107 id: assignUniqueGroupId(baseGroupId, usedGroupIds),
108 terminalIds: groupTerminalIds,
109 });
110 }
111
112 for (const terminalId of terminalIds) {
113 if (assignedTerminalIds.has(terminalId)) continue;
114 nextGroups.push({
115 id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds),
116 terminalIds: [terminalId],
117 });
118 }
119
120 if (nextGroups.length === 0) {
121 return [
122 {
123 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
124 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
125 },
126 ];
127 }
128
129 return nextGroups;
130}
131
132// ─── Default & normalize ─────────────────────────────────────────────────
133
134const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({
135 terminalOpen: false,
136 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
137 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
138 runningTerminalIds: [],
139 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
140 terminalGroups: [
141 {
142 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
143 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
144 },
145 ],
146 activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
147});
148
149export function createDefaultThreadTerminalState(): ThreadTerminalState {
150 return { ...DEFAULT_THREAD_TERMINAL_STATE };
151}
152
153export function getDefaultThreadTerminalState(): ThreadTerminalState {
154 return DEFAULT_THREAD_TERMINAL_STATE;
155}
156
157export function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState {
158 const terminalIds = normalizeTerminalIds(state.terminalIds);
159 const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID];
160 const runningTerminalIds = normalizeRunningTerminalIds(
161 state.runningTerminalIds,
162 nextTerminalIds,
163 );
164 const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId)
165 ? state.activeTerminalId
166 : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
167 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);
168 const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId)
169 ? state.activeTerminalGroupId
170 : null;
171 const activeGroupIdFromTerminal =
172 terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null;
173
174 return {
175 terminalOpen: state.terminalOpen,
176 terminalHeight:
177 Number.isFinite(state.terminalHeight) && state.terminalHeight > 0
178 ? state.terminalHeight
179 : DEFAULT_THREAD_TERMINAL_HEIGHT,
180 terminalIds: nextTerminalIds,
181 runningTerminalIds,
182 activeTerminalId,
183 terminalGroups,
184 activeTerminalGroupId:
185 activeGroupIdFromState ??
186 activeGroupIdFromTerminal ??
187 terminalGroups[0]?.id ??
188 fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
189 };
190}
191
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);
226
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 });
236}
237
238function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
239 return groups.map((group) => ({
240 id: group.id,
241 terminalIds: [...group.terminalIds],
242 }));
243}
244
245// ─── Single reducer for both draft and persisted thread ────────────────────
246
247export function reduceThreadTerminalState(
248 state: ThreadTerminalState,
249 action: ThreadTerminalAction,
250): ThreadTerminalState {
251 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) {
301 return normalized;
302 }
303
304 const isNewTerminal = !normalized.terminalIds.includes(action.terminalId);
305 if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
306 return normalized;
307 }
308
309 const terminalIds = isNewTerminal
310 ? [...normalized.terminalIds, action.terminalId]
311 : normalized.terminalIds;
312 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);
313 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId);
314 if (existingGroupIndex >= 0) {
315 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[
316 existingGroupIndex
317 ]!.terminalIds.filter((id) => id !== action.terminalId);
318 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
319 terminalGroups.splice(existingGroupIndex, 1);
320 }
321 }
322
323 if (action.type === "new") {
324 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] });
327 return normalizeThreadTerminalState({
328 ...normalized,
329 terminalOpen: true,
330 terminalIds,
331 activeTerminalId: action.terminalId,
332 terminalGroups,
333 activeTerminalGroupId: nextGroupId,
334 });
335 }
336
337 // split
338 let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId);
339 if (activeGroupIndex < 0) {
340 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);
341 }
342 if (activeGroupIndex < 0) {
343 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
344 const nextGroupId = assignUniqueGroupId(
345 fallbackGroupId(normalized.activeTerminalId),
346 usedGroupIds,
347 );
348 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });
349 activeGroupIndex = terminalGroups.length - 1;
350 }
351
352 const destinationGroup = terminalGroups[activeGroupIndex];
353 if (!destinationGroup) {
354 return normalized;
355 }
356
357 if (!destinationGroup.terminalIds.includes(action.terminalId)) {
358 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);
359 if (anchorIndex >= 0) {
360 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId);
361 } else {
362 destinationGroup.terminalIds.push(action.terminalId);
363 }
364 }
365
366 return normalizeThreadTerminalState({
367 ...normalized,
368 terminalOpen: true,
369 terminalIds,
370 activeTerminalId: action.terminalId,
371 terminalGroups,
372 activeTerminalGroupId: destinationGroup.id,
373 });
374}