diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 03af546d5611f08d79fe3c866b211207bb2e9487..e54de6671f753022dd0e0fde1bc2fc9f2d7ab9a0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -61,11 +61,7 @@ import { type Thread, type TurnDiffSummary, } from "../types"; -import { - createDefaultDraftThreadTerminalState, - reduceDraftThreadTerminalState, - type DraftThreadTerminalState, -} from "../draftThreadTerminalState"; +import type { ThreadTerminalState } from "../threadTerminalState"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -209,7 +205,7 @@ function buildExpandedImagePreview( function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, - draftTerminalState: DraftThreadTerminalState, + draftTerminalState: ThreadTerminalState, fallbackModel: string, error: string | null, ): Thread { @@ -239,7 +235,7 @@ function buildLocalDraftThread( }; } -function threadTerminalsMatchState(thread: Thread, terminalState: DraftThreadTerminalState): boolean { +function threadTerminalsMatchState(thread: Thread, terminalState: ThreadTerminalState): boolean { if ( thread.terminalOpen !== terminalState.terminalOpen || thread.terminalHeight !== terminalState.terminalHeight || @@ -547,9 +543,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record >({}); - const [draftTerminalStateByThreadId, setDraftTerminalStateByThreadId] = useState< - Record - >({}); const [sendPhase, setSendPhase] = useState("idle"); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); @@ -585,24 +578,12 @@ export default function ChatView({ threadId }: ChatViewProps) { setMessagesScrollElement(element); }, []); - const updateDraftTerminalState = useCallback( - ( - targetThreadId: ThreadId, - updater: (state: DraftThreadTerminalState) => DraftThreadTerminalState, - ) => { - setDraftTerminalStateByThreadId((existing) => { - const previous = existing[targetThreadId] ?? createDefaultDraftThreadTerminalState(); - const next = updater(previous); - if (next === previous) { - return existing; - } - return { - ...existing, - [targetThreadId]: next, - }; - }); - }, - [], + const setDraftThreadTerminalAction = useComposerDraftStore((s) => s.setDraftThreadTerminalAction); + const clearDraftThreadTerminalState = useComposerDraftStore( + (s) => s.clearDraftThreadTerminalState, + ); + const localDraftTerminalState = useComposerDraftStore((s) => + s.getDraftThreadTerminalState(threadId), ); const setPrompt = useCallback( @@ -637,14 +618,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; - const localDraftTerminalState = draftTerminalStateByThreadId[threadId]; const localDraftThread = useMemo( () => draftThread ? buildLocalDraftThread( threadId, draftThread, - localDraftTerminalState ?? createDefaultDraftThreadTerminalState(), + localDraftTerminalState, fallbackDraftProject?.model ?? DEFAULT_MODEL, localDraftError, ) @@ -681,25 +661,23 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [serverThread, threadId]); useEffect(() => { - if (!serverThread || !localDraftTerminalState) { + if (!serverThread) { return; } - if (!threadTerminalsMatchState(serverThread, localDraftTerminalState)) { + const draft = useComposerDraftStore.getState().draftThreadsByThreadId[threadId]; + const draftTerminal = draft?.terminalState; + if (draftTerminal === undefined) { + return; + } + if (!threadTerminalsMatchState(serverThread, draftTerminal)) { dispatch({ type: "HYDRATE_THREAD_TERMINALS", threadId: serverThread.id, - terminalState: localDraftTerminalState, + terminalState: draftTerminal, }); - return; } - setDraftTerminalStateByThreadId((existing) => { - if (!(threadId in existing)) { - return existing; - } - const { [threadId]: _removed, ...rest } = existing; - return rest as Record; - }); - }, [dispatch, localDraftTerminalState, serverThread, threadId]); + clearDraftThreadTerminalState(threadId); + }, [clearDraftThreadTerminalState, dispatch, serverThread, threadId]); useEffect(() => { if (!activeThread?.id) return; @@ -1108,12 +1086,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (open: boolean) => { if (!activeThreadId) return; if (isLocalDraftThread) { - updateDraftTerminalState(activeThreadId, (draftState) => - reduceDraftThreadTerminalState(draftState, { - type: "set-open", - open, - }), - ); + setDraftThreadTerminalAction(activeThreadId, { type: "set-open", open }); return; } dispatch({ @@ -1122,18 +1095,13 @@ export default function ChatView({ threadId }: ChatViewProps) { open, }); }, - [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], + [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], ); const setTerminalHeight = useCallback( (height: number) => { if (!activeThreadId) return; if (isLocalDraftThread) { - updateDraftTerminalState(activeThreadId, (draftState) => - reduceDraftThreadTerminalState(draftState, { - type: "set-height", - height, - }), - ); + setDraftThreadTerminalAction(activeThreadId, { type: "set-height", height }); return; } dispatch({ @@ -1142,7 +1110,7 @@ export default function ChatView({ threadId }: ChatViewProps) { height, }); }, - [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], + [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; @@ -1152,12 +1120,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activeThreadId || hasReachedTerminalLimit) return; const terminalId = `terminal-${crypto.randomUUID()}`; if (isLocalDraftThread) { - updateDraftTerminalState(activeThreadId, (draftState) => - reduceDraftThreadTerminalState(draftState, { - type: "split", - terminalId, - }), - ); + setDraftThreadTerminalAction(activeThreadId, { type: "split", terminalId }); } else { dispatch({ type: "SPLIT_THREAD_TERMINAL", @@ -1166,17 +1129,12 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]); + }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]); const createNewTerminal = useCallback(() => { if (!activeThreadId || hasReachedTerminalLimit) return; const terminalId = `terminal-${crypto.randomUUID()}`; if (isLocalDraftThread) { - updateDraftTerminalState(activeThreadId, (draftState) => - reduceDraftThreadTerminalState(draftState, { - type: "new", - terminalId, - }), - ); + setDraftThreadTerminalAction(activeThreadId, { type: "new", terminalId }); } else { dispatch({ type: "NEW_THREAD_TERMINAL", @@ -1185,17 +1143,12 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]); + }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; if (isLocalDraftThread) { - updateDraftTerminalState(activeThreadId, (draftState) => - reduceDraftThreadTerminalState(draftState, { - type: "set-active", - terminalId, - }), - ); + setDraftThreadTerminalAction(activeThreadId, { type: "set-active", terminalId }); } else { dispatch({ type: "SET_THREAD_ACTIVE_TERMINAL", @@ -1205,7 +1158,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } setTerminalFocusRequestId((value) => value + 1); }, - [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], + [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], ); const closeTerminal = useCallback( (terminalId: string) => { @@ -1229,12 +1182,7 @@ export default function ChatView({ threadId }: ChatViewProps) { void fallbackExitWrite(); } if (isLocalDraftThread) { - updateDraftTerminalState(activeThreadId, (draftState) => - reduceDraftThreadTerminalState(draftState, { - type: "close", - terminalId, - }), - ); + setDraftThreadTerminalAction(activeThreadId, { type: "close", terminalId }); } else { dispatch({ type: "CLOSE_THREAD_TERMINAL", @@ -1244,7 +1192,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } setTerminalFocusRequestId((value) => value + 1); }, - [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], + [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], ); const runProjectScript = useCallback( async ( @@ -1281,12 +1229,10 @@ export default function ChatView({ threadId }: ChatViewProps) { setTerminalOpen(true); if (shouldCreateNewTerminal) { if (isLocalDraftThread) { - updateDraftTerminalState(activeThreadId, (draftState) => - reduceDraftThreadTerminalState(draftState, { - type: "new", - terminalId: targetTerminalId, - }), - ); + setDraftThreadTerminalAction(activeThreadId, { + type: "new", + terminalId: targetTerminalId, + }); } else { dispatch({ type: "NEW_THREAD_TERMINAL", @@ -1296,12 +1242,10 @@ export default function ChatView({ threadId }: ChatViewProps) { } } else { if (isLocalDraftThread) { - updateDraftTerminalState(activeThreadId, (draftState) => - reduceDraftThreadTerminalState(draftState, { - type: "set-active", - terminalId: targetTerminalId, - }), - ); + setDraftThreadTerminalAction(activeThreadId, { + type: "set-active", + terminalId: targetTerminalId, + }); } else { dispatch({ type: "SET_THREAD_ACTIVE_TERMINAL", @@ -1355,7 +1299,7 @@ export default function ChatView({ threadId }: ChatViewProps) { isServerThread, setTerminalOpen, setThreadError, - updateDraftTerminalState, + setDraftThreadTerminalAction, ], ); const persistProjectScripts = useCallback( diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 47fdd719221d3daa03dde04f76ff51c597d91f46..c5453e23edef93edb2d51f9913108282c6bd6026 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -6,6 +6,13 @@ import { normalizeModelSlug, type ReasoningEffort, } from "@t3tools/contracts"; +import { + createDefaultThreadTerminalState, + getDefaultThreadTerminalState, + reduceThreadTerminalState, + type ThreadTerminalAction, + type ThreadTerminalState, +} from "./threadTerminalState"; import type { ChatImageAttachment } from "./types"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -64,6 +71,8 @@ export interface DraftThreadState { branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; + /** Terminal layout for draft thread; same shape as Thread terminal slice. Omitted when not set. */ + terminalState?: ThreadTerminalState; } interface ProjectDraftThread extends DraftThreadState { @@ -113,6 +122,9 @@ interface ComposerDraftStoreState { ) => void; clearComposerContent: (threadId: ThreadId) => void; clearThreadDraft: (threadId: ThreadId) => void; + getDraftThreadTerminalState: (threadId: ThreadId) => ThreadTerminalState; + setDraftThreadTerminalAction: (threadId: ThreadId, action: ThreadTerminalAction) => void; + clearDraftThreadTerminalState: (threadId: ThreadId) => void; } const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { @@ -666,6 +678,53 @@ export const useComposerDraftStore = create()( }; }); }, + getDraftThreadTerminalState: (threadId) => { + if (threadId.length === 0) { + return getDefaultThreadTerminalState(); + } + const draft = get().draftThreadsByThreadId[threadId]; + return draft?.terminalState ?? getDefaultThreadTerminalState(); + }, + setDraftThreadTerminalAction: (threadId, action) => { + if (threadId.length === 0) { + return; + } + set((state) => { + const existing = state.draftThreadsByThreadId[threadId]; + if (!existing) { + return state; + } + const current = existing.terminalState ?? createDefaultThreadTerminalState(); + const next = reduceThreadTerminalState(current, action); + if (next === current) { + return state; + } + return { + draftThreadsByThreadId: { + ...state.draftThreadsByThreadId, + [threadId]: { ...existing, terminalState: next }, + }, + }; + }); + }, + clearDraftThreadTerminalState: (threadId) => { + if (threadId.length === 0) { + return; + } + set((state) => { + const existing = state.draftThreadsByThreadId[threadId]; + if (!existing || existing.terminalState === undefined) { + return state; + } + const { terminalState: _removed, ...rest } = existing; + return { + draftThreadsByThreadId: { + ...state.draftThreadsByThreadId, + [threadId]: rest, + }, + }; + }); + }, setPrompt: (threadId, prompt) => { if (threadId.length === 0) { return; diff --git a/apps/web/src/draftThreadTerminalState.ts b/apps/web/src/draftThreadTerminalState.ts index 0e8d23e25f8fad42e59467291cf855e73ff8db7c..e7e9fa3f61821761a898c69fd35c406d69c619fb 100644 --- a/apps/web/src/draftThreadTerminalState.ts +++ b/apps/web/src/draftThreadTerminalState.ts @@ -1,332 +1,20 @@ +/** + * Re-exports shared thread terminal state and reducer so draft and persisted + * flows use the same shape and logic. See threadTerminalState.ts. + */ + import { - DEFAULT_THREAD_TERMINAL_HEIGHT, - DEFAULT_THREAD_TERMINAL_ID, - MAX_THREAD_TERMINAL_COUNT, - type ThreadTerminalGroup, -} from "./types"; + createDefaultThreadTerminalState, + normalizeThreadTerminalState, + reduceThreadTerminalState, + type ThreadTerminalAction, + type ThreadTerminalState, +} from "./threadTerminalState"; -export interface DraftThreadTerminalState { - terminalOpen: boolean; - terminalHeight: number; - terminalIds: string[]; - runningTerminalIds: string[]; - activeTerminalId: string; - terminalGroups: ThreadTerminalGroup[]; - activeTerminalGroupId: string; -} +export type DraftThreadTerminalState = ThreadTerminalState; +export type DraftThreadTerminalAction = ThreadTerminalAction; -export type DraftThreadTerminalAction = - | { type: "set-open"; open: boolean } - | { type: "set-height"; height: number } - | { type: "split"; terminalId: string } - | { type: "new"; terminalId: string } - | { type: "set-active"; terminalId: string } - | { type: "close"; terminalId: string } - | { type: "set-activity"; terminalId: string; hasRunningSubprocess: boolean }; +export const createDefaultDraftThreadTerminalState = createDefaultThreadTerminalState; +export const reduceDraftThreadTerminalState = reduceThreadTerminalState; -function normalizeTerminalIds(terminalIds: string[]): string[] { - return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; -} - -function fallbackGroupId(terminalId: string): string { - return `group-${terminalId}`; -} - -function assignUniqueGroupId(baseId: string, usedGroupIds: Set): string { - let candidate = baseId; - let index = 2; - while (usedGroupIds.has(candidate)) { - candidate = `${baseId}-${index}`; - index += 1; - } - usedGroupIds.add(candidate); - return candidate; -} - -function findGroupIndexByTerminalId(terminalGroups: ThreadTerminalGroup[], terminalId: string): number { - return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); -} - -function normalizeTerminalGroups( - terminalGroups: ThreadTerminalGroup[], - terminalIds: string[], -): ThreadTerminalGroup[] { - const validTerminalIdSet = new Set(terminalIds); - const assignedTerminalIds = new Set(); - const nextGroups: ThreadTerminalGroup[] = []; - const usedGroupIds = new Set(); - - for (const group of terminalGroups) { - const groupTerminalIds = normalizeTerminalIds(group.terminalIds).filter((terminalId) => { - if (!validTerminalIdSet.has(terminalId)) return false; - if (assignedTerminalIds.has(terminalId)) return false; - return true; - }); - if (groupTerminalIds.length === 0) { - continue; - } - - for (const terminalId of groupTerminalIds) { - assignedTerminalIds.add(terminalId); - } - - nextGroups.push({ - id: assignUniqueGroupId(group.id, usedGroupIds), - terminalIds: groupTerminalIds, - }); - } - - for (const terminalId of terminalIds) { - if (assignedTerminalIds.has(terminalId)) continue; - nextGroups.push({ - id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), - terminalIds: [terminalId], - }); - } - - if (nextGroups.length === 0) { - return [ - { - id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ]; - } - - return nextGroups; -} - -export function createDefaultDraftThreadTerminalState(): DraftThreadTerminalState { - return { - terminalOpen: false, - terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: [], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), - }; -} - -function normalizeDraftThreadTerminals(state: DraftThreadTerminalState): DraftThreadTerminalState { - const terminalIds = normalizeTerminalIds(state.terminalIds); - const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; - const runningTerminalIds = normalizeTerminalIds(state.runningTerminalIds).filter((terminalId) => - nextTerminalIds.includes(terminalId), - ); - const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) - ? state.activeTerminalId - : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); - const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); - const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId) - ? state.activeTerminalGroupId - : null; - const activeGroupIdFromTerminal = - terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; - - return { - terminalOpen: state.terminalOpen, - terminalHeight: - Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 - ? state.terminalHeight - : DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: nextTerminalIds, - runningTerminalIds, - activeTerminalId, - terminalGroups, - activeTerminalGroupId: - activeGroupIdFromState ?? - activeGroupIdFromTerminal ?? - terminalGroups[0]?.id ?? - fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), - }; -} - -function closeDraftTerminal( - state: DraftThreadTerminalState, - terminalId: string, -): DraftThreadTerminalState { - if (!state.terminalIds.includes(terminalId)) { - return state; - } - - const remainingTerminalIds = state.terminalIds.filter((id) => id !== terminalId); - if (remainingTerminalIds.length === 0) { - return createDefaultDraftThreadTerminalState(); - } - - const closedTerminalIndex = state.terminalIds.indexOf(terminalId); - const nextActiveTerminalId = - state.activeTerminalId === terminalId - ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? - remainingTerminalIds[0] ?? - DEFAULT_THREAD_TERMINAL_ID) - : state.activeTerminalId; - - const terminalGroups = state.terminalGroups - .map((group) => ({ - ...group, - terminalIds: group.terminalIds.filter((id) => id !== terminalId), - })) - .filter((group) => group.terminalIds.length > 0); - - const nextActiveTerminalGroupId = - terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? - terminalGroups[0]?.id ?? - fallbackGroupId(nextActiveTerminalId); - - return normalizeDraftThreadTerminals({ - terminalOpen: state.terminalOpen, - terminalHeight: state.terminalHeight, - terminalIds: remainingTerminalIds, - runningTerminalIds: state.runningTerminalIds.filter((id) => id !== terminalId), - activeTerminalId: nextActiveTerminalId, - terminalGroups, - activeTerminalGroupId: nextActiveTerminalGroupId, - }); -} - -function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { - return groups.map((group) => ({ - id: group.id, - terminalIds: [...group.terminalIds], - })); -} - -export function reduceDraftThreadTerminalState( - state: DraftThreadTerminalState, - action: DraftThreadTerminalAction, -): DraftThreadTerminalState { - const normalized = normalizeDraftThreadTerminals(state); - - if (action.type === "set-open") { - if (normalized.terminalOpen === action.open) return normalized; - return { - ...normalized, - terminalOpen: action.open, - }; - } - - if (action.type === "set-height") { - if (!Number.isFinite(action.height) || action.height <= 0 || normalized.terminalHeight === action.height) { - return normalized; - } - return { - ...normalized, - terminalHeight: action.height, - }; - } - - if (action.type === "set-active") { - if (!normalized.terminalIds.includes(action.terminalId)) { - return normalized; - } - const activeTerminalGroupId = - normalized.terminalGroups.find((group) => group.terminalIds.includes(action.terminalId))?.id ?? - normalized.activeTerminalGroupId; - return { - ...normalized, - activeTerminalId: action.terminalId, - activeTerminalGroupId, - }; - } - - if (action.type === "close") { - return closeDraftTerminal(normalized, action.terminalId); - } - - if (action.type === "set-activity") { - if (!normalized.terminalIds.includes(action.terminalId)) { - return normalized; - } - const runningTerminalIds = new Set(normalized.runningTerminalIds); - if (action.hasRunningSubprocess) { - runningTerminalIds.add(action.terminalId); - } else { - runningTerminalIds.delete(action.terminalId); - } - return { - ...normalized, - runningTerminalIds: [...runningTerminalIds], - }; - } - - if (!action.terminalId || action.terminalId.trim().length === 0) { - return normalized; - } - - const isNewTerminal = !normalized.terminalIds.includes(action.terminalId); - if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { - return normalized; - } - - const terminalIds = isNewTerminal - ? [...normalized.terminalIds, action.terminalId] - : normalized.terminalIds; - const terminalGroups = copyTerminalGroups(normalized.terminalGroups); - const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId); - if (existingGroupIndex >= 0) { - terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter( - (id) => id !== action.terminalId, - ); - if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { - terminalGroups.splice(existingGroupIndex, 1); - } - } - - if (action.type === "new") { - const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); - const nextGroupId = assignUniqueGroupId(fallbackGroupId(action.terminalId), usedGroupIds); - terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] }); - return normalizeDraftThreadTerminals({ - ...normalized, - terminalOpen: true, - terminalIds, - activeTerminalId: action.terminalId, - terminalGroups, - activeTerminalGroupId: nextGroupId, - }); - } - - let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId); - if (activeGroupIndex < 0) { - activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); - } - if (activeGroupIndex < 0) { - const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); - const nextGroupId = assignUniqueGroupId( - fallbackGroupId(normalized.activeTerminalId), - usedGroupIds, - ); - terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); - activeGroupIndex = terminalGroups.length - 1; - } - - const destinationGroup = terminalGroups[activeGroupIndex]; - if (!destinationGroup) { - return normalized; - } - - if (!destinationGroup.terminalIds.includes(action.terminalId)) { - const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); - if (anchorIndex >= 0) { - destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId); - } else { - destinationGroup.terminalIds.push(action.terminalId); - } - } - - return normalizeDraftThreadTerminals({ - ...normalized, - terminalOpen: true, - terminalIds, - activeTerminalId: action.terminalId, - terminalGroups, - activeTerminalGroupId: destinationGroup.id, - }); -} +export { normalizeThreadTerminalState }; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 791254edb7e5df864265ecb3999449f8b5fbd063..9e27c6cc65f8ed96f9de0cc7da3967f1538c24b4 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -10,34 +10,24 @@ import { resolveModelSlug, } from "@t3tools/contracts"; import { - DEFAULT_THREAD_TERMINAL_HEIGHT, - DEFAULT_THREAD_TERMINAL_ID, + createDefaultThreadTerminalState, + normalizeThreadTerminalState, + reduceThreadTerminalState, + type ThreadTerminalState, +} from "./threadTerminalState"; +import { DEFAULT_RUNTIME_MODE, - MAX_THREAD_TERMINAL_COUNT, type ChatMessage, type Project, type RuntimeMode, type Thread, - type ThreadTerminalGroup, } from "./types"; // ── Actions ────────────────────────────────────────────────────────── type Action = | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } - | { - type: "HYDRATE_THREAD_TERMINALS"; - threadId: ThreadId; - terminalState: { - terminalOpen: boolean; - terminalHeight: number; - terminalIds: string[]; - runningTerminalIds: string[]; - activeTerminalId: string; - terminalGroups: ThreadTerminalGroup[]; - activeTerminalGroupId: string; - }; - } + | { type: "HYDRATE_THREAD_TERMINALS"; threadId: ThreadId; terminalState: ThreadTerminalState } | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string } | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId } | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } @@ -262,210 +252,24 @@ function attachmentPreviewRoutePath(attachmentId: string): string { return `/attachments/${encodeURIComponent(attachmentId)}`; } -function normalizeTerminalIds(terminalIds: string[]): string[] { - const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); - const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); - if (unique.length > 0) { - return unique; - } - return [DEFAULT_THREAD_TERMINAL_ID]; -} - -function normalizeRunningTerminalIds( - runningTerminalIds: string[], - terminalIds: string[], -): string[] { - if (runningTerminalIds.length === 0) return []; - const validTerminalIdSet = new Set(terminalIds); - return [...new Set(runningTerminalIds)] - .map((id) => id.trim()) - .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); -} - -function normalizeTerminalGroupIds(terminalIds: string[]): string[] { - return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; -} - -function fallbackGroupId(terminalId: string): string { - return `group-${terminalId}`; -} - -function assignUniqueGroupId(groupId: string, usedGroupIds: Set): string { - if (!usedGroupIds.has(groupId)) { - usedGroupIds.add(groupId); - return groupId; - } - let suffix = 2; - while (usedGroupIds.has(`${groupId}-${suffix}`)) { - suffix += 1; - } - const uniqueGroupId = `${groupId}-${suffix}`; - usedGroupIds.add(uniqueGroupId); - return uniqueGroupId; -} - -function normalizeTerminalGroups(thread: Thread, terminalIds: string[]): ThreadTerminalGroup[] { - const validTerminalIdSet = new Set(terminalIds); - const assignedTerminalIds = new Set(); - const usedGroupIds = new Set(); - const groups: ThreadTerminalGroup[] = []; - - for (const group of thread.terminalGroups) { - const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { - if (!validTerminalIdSet.has(terminalId)) return false; - if (assignedTerminalIds.has(terminalId)) return false; - return true; - }); - if (groupTerminalIds.length === 0) continue; - for (const terminalId of groupTerminalIds) { - assignedTerminalIds.add(terminalId); - } - const baseGroupId = - group.id.trim().length > 0 - ? group.id.trim() - : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); - groups.push({ - id: assignUniqueGroupId(baseGroupId, usedGroupIds), - terminalIds: groupTerminalIds, - }); - } - - for (const terminalId of terminalIds) { - if (assignedTerminalIds.has(terminalId)) continue; - groups.push({ - id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), - terminalIds: [terminalId], - }); - } - - if (groups.length > 0) { - return groups; - } - - return [ - { - id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ]; -} - -function findGroupIndexByTerminalId( - terminalGroups: ThreadTerminalGroup[], - terminalId: string, -): number { - return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); +function threadTerminalSlice(thread: Thread): ThreadTerminalState { + return { + terminalOpen: thread.terminalOpen, + terminalHeight: thread.terminalHeight, + terminalIds: thread.terminalIds, + runningTerminalIds: thread.runningTerminalIds, + activeTerminalId: thread.activeTerminalId, + terminalGroups: thread.terminalGroups, + activeTerminalGroupId: thread.activeTerminalGroupId, + }; +} + +function applyTerminalState(thread: Thread, terminal: ThreadTerminalState): Thread { + return { ...thread, ...terminal }; } function normalizeThreadTerminals(thread: Thread): Thread { - const terminalIds = normalizeTerminalIds(thread.terminalIds); - const activeTerminalId = terminalIds.includes(thread.activeTerminalId) - ? thread.activeTerminalId - : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); - const terminalGroups = normalizeTerminalGroups(thread, terminalIds); - const activeGroupIndexFromId = terminalGroups.findIndex( - (group) => group.id === thread.activeTerminalGroupId, - ); - const activeGroupIndexFromTerminal = findGroupIndexByTerminalId(terminalGroups, activeTerminalId); - const activeGroupIndex = - activeGroupIndexFromId >= 0 - ? activeGroupIndexFromId - : activeGroupIndexFromTerminal >= 0 - ? activeGroupIndexFromTerminal - : 0; - const activeTerminalGroupId = - terminalGroups[activeGroupIndex]?.id ?? - terminalGroups[0]?.id ?? - fallbackGroupId(activeTerminalId); - - return { - ...thread, - terminalIds, - runningTerminalIds: normalizeRunningTerminalIds(thread.runningTerminalIds, terminalIds), - activeTerminalId, - terminalGroups, - activeTerminalGroupId, - }; -} - -function closeThreadTerminal(thread: Thread, terminalId: string): Thread { - if (!thread.terminalIds.includes(terminalId)) { - return thread; - } - - const remainingTerminalIds = thread.terminalIds.filter((id) => id !== terminalId); - if (remainingTerminalIds.length === 0) { - const nextTerminalGroupId = fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID); - return normalizeThreadTerminals({ - ...thread, - terminalOpen: false, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: [], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: nextTerminalGroupId, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: nextTerminalGroupId, - }); - } - - const closedTerminalIndex = thread.terminalIds.indexOf(terminalId); - const closedTerminalGroup = thread.terminalGroups.find((group) => - group.terminalIds.includes(terminalId), - ); - const closedTerminalGroupIndex = closedTerminalGroup - ? closedTerminalGroup.terminalIds.indexOf(terminalId) - : -1; - const remainingTerminalsInClosedGroup = (closedTerminalGroup?.terminalIds ?? []).filter( - (id) => id !== terminalId, - ); - const nextActiveTerminalId = - thread.activeTerminalId === terminalId - ? (remainingTerminalsInClosedGroup[ - Math.min(closedTerminalGroupIndex, remainingTerminalsInClosedGroup.length - 1) - ] ?? - remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? - remainingTerminalIds[0] ?? - DEFAULT_THREAD_TERMINAL_ID) - : thread.activeTerminalId; - const nextTerminalGroups = thread.terminalGroups - .map((group) => ({ - ...group, - terminalIds: group.terminalIds.filter((id) => id !== terminalId), - })) - .filter((group) => group.terminalIds.length > 0); - - return normalizeThreadTerminals({ - ...thread, - terminalIds: remainingTerminalIds, - runningTerminalIds: thread.runningTerminalIds.filter((id) => id !== terminalId), - activeTerminalId: nextActiveTerminalId, - terminalGroups: nextTerminalGroups, - }); -} - -function setThreadTerminalActivity( - thread: Thread, - terminalId: string, - hasRunningSubprocess: boolean, -): Thread { - const normalizedThread = normalizeThreadTerminals(thread); - if (!normalizedThread.terminalIds.includes(terminalId)) { - return normalizedThread; - } - const runningTerminalIds = new Set(normalizedThread.runningTerminalIds); - if (hasRunningSubprocess) { - runningTerminalIds.add(terminalId); - } else { - runningTerminalIds.delete(terminalId); - } - return normalizeThreadTerminals({ - ...normalizedThread, - runningTerminalIds: [...runningTerminalIds], - }); + return applyTerminalState(thread, normalizeThreadTerminalState(threadTerminalSlice(thread))); } function syncServerReadModelState(state: AppState, readModel: OrchestrationReadModel): AppState { @@ -489,10 +293,14 @@ export function reducer(state: AppState, action: Action): AppState { const existingThreadById = new Map( state.threads.map((thread) => [thread.id, thread] as const), ); + const defaultTerminal = createDefaultThreadTerminalState(); const threads = action.readModel.threads .filter((thread) => thread.deletedAt === null) .map((thread) => { const existing = existingThreadById.get(thread.id); + const terminalSlice: ThreadTerminalState = existing + ? threadTerminalSlice(existing) + : defaultTerminal; return normalizeThreadTerminals({ id: thread.id, @@ -500,19 +308,7 @@ export function reducer(state: AppState, action: Action): AppState { projectId: thread.projectId, title: thread.title, model: resolveModelSlug(thread.model), - terminalOpen: existing?.terminalOpen ?? false, - terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: existing?.runningTerminalIds ?? [], - activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: existing?.terminalGroups ?? [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: - existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, + ...terminalSlice, session: thread.session ? { sessionId: @@ -578,16 +374,7 @@ export function reducer(state: AppState, action: Action): AppState { return { ...state, threads: updateThread(state.threads, action.threadId, (thread) => - normalizeThreadTerminals({ - ...thread, - terminalOpen: action.terminalState.terminalOpen, - terminalHeight: action.terminalState.terminalHeight, - terminalIds: action.terminalState.terminalIds, - runningTerminalIds: action.terminalState.runningTerminalIds, - activeTerminalId: action.terminalState.activeTerminalId, - terminalGroups: action.terminalState.terminalGroups, - activeTerminalGroupId: action.terminalState.activeTerminalGroupId, - }), + applyTerminalState(thread, normalizeThreadTerminalState(action.terminalState)), ), }; @@ -648,7 +435,14 @@ export function reducer(state: AppState, action: Action): AppState { return { ...state, threads: updateThread(state.threads, action.threadId, (thread) => - setThreadTerminalActivity(thread, action.terminalId, action.hasRunningSubprocess), + applyTerminalState( + thread, + reduceThreadTerminalState(threadTerminalSlice(thread), { + type: "set-activity", + terminalId: action.terminalId, + hasRunningSubprocess: action.hasRunningSubprocess, + }), + ), ), }; @@ -663,54 +457,123 @@ export function reducer(state: AppState, action: Action): AppState { case "TOGGLE_THREAD_TERMINAL": return { ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - terminalOpen: !t.terminalOpen, - })), + threads: updateThread(state.threads, action.threadId, (thread) => + applyTerminalState( + thread, + reduceThreadTerminalState(threadTerminalSlice(thread), { + type: "set-open", + open: !thread.terminalOpen, + }), + ), + ), }; case "SET_THREAD_TERMINAL_OPEN": return { ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - terminalOpen: action.open, - })), + threads: updateThread(state.threads, action.threadId, (thread) => + applyTerminalState( + thread, + reduceThreadTerminalState(threadTerminalSlice(thread), { + type: "set-open", + open: action.open, + }), + ), + ), }; case "SET_THREAD_TERMINAL_HEIGHT": return { ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - terminalHeight: action.height, - })), + threads: updateThread(state.threads, action.threadId, (thread) => + applyTerminalState( + thread, + reduceThreadTerminalState(threadTerminalSlice(thread), { + type: "set-height", + height: action.height, + }), + ), + ), }; case "SPLIT_THREAD_TERMINAL": return { ...state, - threads: updateThread(state.threads, action.threadId, (thread) => { - const normalizedThread = normalizeThreadTerminals(thread); - const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId); - if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { - return normalizedThread; - } - const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) - ? normalizedThread.terminalIds - : [...normalizedThread.terminalIds, action.terminalId]; - const terminalGroups = normalizedThread.terminalGroups.map((group) => ({ - ...group, - terminalIds: [...group.terminalIds], - })); - const normalizedMessage: ChatMessage = { - id: message.id, - role: message.role, - text: message.text, - createdAt: message.createdAt, - streaming: message.streaming, - ...(message.streaming ? {} : { completedAt: message.updatedAt }), - ...(attachments && attachments.length > 0 ? { attachments } : {}), + threads: updateThread(state.threads, action.threadId, (thread) => + applyTerminalState( + thread, + reduceThreadTerminalState(threadTerminalSlice(thread), { + type: "split", + terminalId: action.terminalId, + }), + ), + ), + }; + + case "NEW_THREAD_TERMINAL": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => + applyTerminalState( + thread, + reduceThreadTerminalState(threadTerminalSlice(thread), { + type: "new", + terminalId: action.terminalId, + }), + ), + ), + }; + + case "SET_THREAD_ACTIVE_TERMINAL": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => + applyTerminalState( + thread, + reduceThreadTerminalState(threadTerminalSlice(thread), { + type: "set-active", + terminalId: action.terminalId, + }), + ), + ), + }; + + case "CLOSE_THREAD_TERMINAL": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => + applyTerminalState( + thread, + reduceThreadTerminalState(threadTerminalSlice(thread), { + type: "close", + terminalId: action.terminalId, + }), + ), + ), + }; + + case "SET_ERROR": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (t) => ({ + ...t, + error: action.error, + })), + }; + + case "SET_THREAD_BRANCH": { + return { + ...state, + threads: updateThread(state.threads, action.threadId, (t) => { + // When the effective cwd changes (worktreePath differs), the old + // session is no longer valid — clear it so ensureSession creates a + // new one with the correct cwd on the next message. + const cwdChanged = t.worktreePath !== action.worktreePath; + return { + ...t, + branch: action.branch, + worktreePath: action.worktreePath, + ...(cwdChanged ? { session: null } : {}), }; return normalizedMessage; }), diff --git a/apps/web/src/threadTerminalState.ts b/apps/web/src/threadTerminalState.ts new file mode 100644 index 0000000000000000000000000000000000000000..be0b6466b559fe147268b695edf24048facb6427 --- /dev/null +++ b/apps/web/src/threadTerminalState.ts @@ -0,0 +1,374 @@ +/** + * Shared thread terminal state and reducer. Used by both the main app store + * (persisted threads) and the composer draft store (draft threads), so we can + * reuse the same shape and helpers and easily copy terminal state from draft + * to thread when reconciling server state. + */ + +import { + DEFAULT_THREAD_TERMINAL_HEIGHT, + DEFAULT_THREAD_TERMINAL_ID, + MAX_THREAD_TERMINAL_COUNT, + type ThreadTerminalGroup, +} from "./types"; + +/** Terminal slice shared by Thread and draft thread. Same shape in both stores. */ +export interface ThreadTerminalState { + terminalOpen: boolean; + terminalHeight: number; + terminalIds: string[]; + runningTerminalIds: string[]; + activeTerminalId: string; + terminalGroups: ThreadTerminalGroup[]; + activeTerminalGroupId: string; +} + +export type ThreadTerminalAction = + | { type: "set-open"; open: boolean } + | { type: "set-height"; height: number } + | { type: "split"; terminalId: string } + | { type: "new"; terminalId: string } + | { type: "set-active"; terminalId: string } + | { type: "close"; terminalId: string } + | { type: "set-activity"; terminalId: string; hasRunningSubprocess: boolean }; + +// ─── Helpers (exported for store reducers that need to work with Thread) ─── + +export function normalizeTerminalIds(terminalIds: string[]): string[] { + const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice( + 0, + MAX_THREAD_TERMINAL_COUNT, + ); + return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; +} + +export function normalizeRunningTerminalIds( + runningTerminalIds: string[], + terminalIds: string[], +): string[] { + if (runningTerminalIds.length === 0) return []; + const validTerminalIdSet = new Set(terminalIds); + return [...new Set(runningTerminalIds)] + .map((id) => id.trim()) + .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); +} + +export function fallbackGroupId(terminalId: string): string { + return `group-${terminalId}`; +} + +function assignUniqueGroupId(baseId: string, usedGroupIds: Set): string { + let candidate = baseId; + let index = 2; + while (usedGroupIds.has(candidate)) { + candidate = `${baseId}-${index}`; + index += 1; + } + usedGroupIds.add(candidate); + return candidate; +} + +export function findGroupIndexByTerminalId( + terminalGroups: ThreadTerminalGroup[], + terminalId: string, +): number { + return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); +} + +function normalizeTerminalGroupIds(terminalIds: string[]): string[] { + return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; +} + +/** Normalize groups to match terminalIds; used by both thread and draft. */ +export function normalizeTerminalGroups( + terminalGroups: ThreadTerminalGroup[], + terminalIds: string[], +): ThreadTerminalGroup[] { + const validTerminalIdSet = new Set(terminalIds); + const assignedTerminalIds = new Set(); + const nextGroups: ThreadTerminalGroup[] = []; + const usedGroupIds = new Set(); + + for (const group of terminalGroups) { + const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { + if (!validTerminalIdSet.has(terminalId)) return false; + if (assignedTerminalIds.has(terminalId)) return false; + return true; + }); + if (groupTerminalIds.length === 0) continue; + for (const terminalId of groupTerminalIds) { + assignedTerminalIds.add(terminalId); + } + const baseGroupId = + group.id.trim().length > 0 + ? group.id.trim() + : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + nextGroups.push({ + id: assignUniqueGroupId(baseGroupId, usedGroupIds), + terminalIds: groupTerminalIds, + }); + } + + for (const terminalId of terminalIds) { + if (assignedTerminalIds.has(terminalId)) continue; + nextGroups.push({ + id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), + terminalIds: [terminalId], + }); + } + + if (nextGroups.length === 0) { + return [ + { + id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ]; + } + + return nextGroups; +} + +// ─── Default & normalize ───────────────────────────────────────────────── + +const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({ + terminalOpen: false, + terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + runningTerminalIds: [], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: [ + { + id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), +}); + +export function createDefaultThreadTerminalState(): ThreadTerminalState { + return { ...DEFAULT_THREAD_TERMINAL_STATE }; +} + +export function getDefaultThreadTerminalState(): ThreadTerminalState { + return DEFAULT_THREAD_TERMINAL_STATE; +} + +export function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState { + const terminalIds = normalizeTerminalIds(state.terminalIds); + const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; + const runningTerminalIds = normalizeRunningTerminalIds( + state.runningTerminalIds, + nextTerminalIds, + ); + const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) + ? state.activeTerminalId + : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); + const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId) + ? state.activeTerminalGroupId + : null; + const activeGroupIdFromTerminal = + terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; + + return { + terminalOpen: state.terminalOpen, + terminalHeight: + Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 + ? state.terminalHeight + : DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: nextTerminalIds, + runningTerminalIds, + activeTerminalId, + terminalGroups, + activeTerminalGroupId: + activeGroupIdFromState ?? + activeGroupIdFromTerminal ?? + terminalGroups[0]?.id ?? + fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + }; +} + +// ─── Close terminal (shared) ───────────────────────────────────────────── + +function closeTerminal( + state: ThreadTerminalState, + terminalId: string, +): ThreadTerminalState { + if (!state.terminalIds.includes(terminalId)) { + return state; + } + + const remainingTerminalIds = state.terminalIds.filter((id) => id !== terminalId); + if (remainingTerminalIds.length === 0) { + return createDefaultThreadTerminalState(); + } + + const closedTerminalIndex = state.terminalIds.indexOf(terminalId); + const nextActiveTerminalId = + state.activeTerminalId === terminalId + ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? + remainingTerminalIds[0] ?? + DEFAULT_THREAD_TERMINAL_ID) + : state.activeTerminalId; + + const terminalGroups = state.terminalGroups + .map((group) => ({ + ...group, + terminalIds: group.terminalIds.filter((id) => id !== terminalId), + })) + .filter((group) => group.terminalIds.length > 0); + + const nextActiveTerminalGroupId = + terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? + terminalGroups[0]?.id ?? + fallbackGroupId(nextActiveTerminalId); + + return normalizeThreadTerminalState({ + terminalOpen: state.terminalOpen, + terminalHeight: state.terminalHeight, + terminalIds: remainingTerminalIds, + runningTerminalIds: state.runningTerminalIds.filter((id) => id !== terminalId), + activeTerminalId: nextActiveTerminalId, + terminalGroups, + activeTerminalGroupId: nextActiveTerminalGroupId, + }); +} + +function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { + return groups.map((group) => ({ + id: group.id, + terminalIds: [...group.terminalIds], + })); +} + +// ─── Single reducer for both draft and persisted thread ──────────────────── + +export function reduceThreadTerminalState( + state: ThreadTerminalState, + action: ThreadTerminalAction, +): ThreadTerminalState { + const normalized = normalizeThreadTerminalState(state); + + if (action.type === "set-open") { + if (normalized.terminalOpen === action.open) return normalized; + return { ...normalized, terminalOpen: action.open }; + } + + if (action.type === "set-height") { + if ( + !Number.isFinite(action.height) || + action.height <= 0 || + normalized.terminalHeight === action.height + ) { + return normalized; + } + return { ...normalized, terminalHeight: action.height }; + } + + if (action.type === "set-active") { + if (!normalized.terminalIds.includes(action.terminalId)) { + return normalized; + } + const activeTerminalGroupId = + normalized.terminalGroups.find((group) => group.terminalIds.includes(action.terminalId))?.id ?? + normalized.activeTerminalGroupId; + return { + ...normalized, + activeTerminalId: action.terminalId, + activeTerminalGroupId, + }; + } + + if (action.type === "close") { + return closeTerminal(normalized, action.terminalId); + } + + if (action.type === "set-activity") { + if (!normalized.terminalIds.includes(action.terminalId)) { + return normalized; + } + const runningTerminalIds = new Set(normalized.runningTerminalIds); + if (action.hasRunningSubprocess) { + runningTerminalIds.add(action.terminalId); + } else { + runningTerminalIds.delete(action.terminalId); + } + return { ...normalized, runningTerminalIds: [...runningTerminalIds] }; + } + + if (!action.terminalId || action.terminalId.trim().length === 0) { + return normalized; + } + + const isNewTerminal = !normalized.terminalIds.includes(action.terminalId); + if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { + return normalized; + } + + const terminalIds = isNewTerminal + ? [...normalized.terminalIds, action.terminalId] + : normalized.terminalIds; + const terminalGroups = copyTerminalGroups(normalized.terminalGroups); + const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId); + if (existingGroupIndex >= 0) { + terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[ + existingGroupIndex + ]!.terminalIds.filter((id) => id !== action.terminalId); + if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { + terminalGroups.splice(existingGroupIndex, 1); + } + } + + if (action.type === "new") { + const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); + const nextGroupId = assignUniqueGroupId(fallbackGroupId(action.terminalId), usedGroupIds); + terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] }); + return normalizeThreadTerminalState({ + ...normalized, + terminalOpen: true, + terminalIds, + activeTerminalId: action.terminalId, + terminalGroups, + activeTerminalGroupId: nextGroupId, + }); + } + + // split + let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId); + if (activeGroupIndex < 0) { + activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); + } + if (activeGroupIndex < 0) { + const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); + const nextGroupId = assignUniqueGroupId( + fallbackGroupId(normalized.activeTerminalId), + usedGroupIds, + ); + terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); + activeGroupIndex = terminalGroups.length - 1; + } + + const destinationGroup = terminalGroups[activeGroupIndex]; + if (!destinationGroup) { + return normalized; + } + + if (!destinationGroup.terminalIds.includes(action.terminalId)) { + const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); + if (anchorIndex >= 0) { + destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId); + } else { + destinationGroup.terminalIds.push(action.terminalId); + } + } + + return normalizeThreadTerminalState({ + ...normalized, + terminalOpen: true, + terminalIds, + activeTerminalId: action.terminalId, + terminalGroups, + activeTerminalGroupId: destinationGroup.id, + }); +}