import { type Dispatch, type ReactNode, createContext, createElement, useContext, useEffect, useReducer,} from "react"; import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts";import { resolveModelSlug } from "./model-logic";import { hydratePersistedState, toPersistedState } from "./persistenceSchema";import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";import { type ChatAttachment, DEFAULT_THREAD_TERMINAL_ID, DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread, type ThreadTerminalGroup,} from "./types"; // ── Actions ────────────────────────────────────────────────────────── type Action = | { type: "ADD_PROJECT"; project: Project } | { type: "SYNC_PROJECTS"; projects: Project[] } | { type: "TOGGLE_PROJECT"; projectId: string } | { type: "ADD_THREAD"; thread: Thread } | { type: "SET_ACTIVE_THREAD"; threadId: string } | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "TOGGLE_DIFF" } | { type: "APPLY_EVENT"; event: ProviderEvent; activeAssistantItemRef: { current: string | null }; } | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string; attachments?: ChatAttachment[]; } | { type: "SET_ERROR"; threadId: string; error: string | null } | { type: "SET_THREAD_TITLE"; threadId: string; title: string } | { type: "SET_THREAD_MODEL"; threadId: string; model: string } | { type: "SET_THREAD_BRANCH"; threadId: string; branch: string | null; worktreePath: string | null; } | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode } | { type: "DELETE_THREAD"; threadId: string }; // ── State ──────────────────────────────────────────────────────────── export interface AppState { projects: Project[]; threads: Thread[]; activeThreadId: string | null; runtimeMode: RuntimeMode; diffOpen: boolean;} const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v6", "t3code:renderer-state:v5", "t3code:renderer-state:v4", "t3code:renderer-state:v3", "codething:renderer-state:v4", "codething:renderer-state:v3", "codething:renderer-state:v2", "codething:renderer-state:v1",] as const; const initialState: AppState = { projects: [], threads: [], activeThreadId: null, runtimeMode: DEFAULT_RUNTIME_MODE, diffOpen: false,}; // ── Helpers ────────────────────────────────────────────────────────── function readPersistedState(): AppState { if (typeof window === "undefined") return initialState; try { const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY); const legacyValues = LEGACY_PERSISTED_STATE_KEYS.map((key) => window.localStorage.getItem(key)); const rawLegacy = legacyValues.find((value) => value !== null) ?? null; const raw = rawCurrent ?? rawLegacy; if (!raw) return initialState; const rawCodethingV1 = window.localStorage.getItem("codething:renderer-state:v1"); const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1); if (!hydrated) return initialState; return { ...hydrated, diffOpen: false }; } catch { return initialState; }} function persistState(state: AppState): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state))); for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { window.localStorage.removeItem(legacyKey); } } catch { // Ignore quota/storage errors to avoid breaking chat UX. }} function updateThread( threads: Thread[], threadId: string, updater: (t: Thread) => Thread,): Thread[] { return threads.map((t) => (t.id === threadId ? updater(t) : t));} function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); const unique = [...new Set(ids)]; 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>): 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<string>(); const usedGroupIds = new Set<string>(); 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 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 findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { return threads.find((t) => t.session?.sessionId === sessionId);} function getEventTurnId(event: ProviderEvent): string | undefined { if (event.turnId) return event.turnId; const payload = asObject(event.payload); const turn = asObject(payload?.turn); return asString(turn?.id);} function getEventThreadId(event: ProviderEvent): string | undefined { if (event.threadId) return event.threadId; const payload = asObject(event.payload); const payloadThread = asObject(payload?.thread); const payloadMessage = asObject(payload?.msg); return ( asString(payload?.threadId) ?? asString(payloadThread?.id) ?? asString(payload?.conversationId) ?? asString(payload?.thread_id) ?? asString(payloadMessage?.thread_id) );} function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean { const eventThreadId = getEventThreadId(event); if (!eventThreadId) { return false; } const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId; if (!expectedThreadId || eventThreadId === expectedThreadId) { return false; } // During connect, accept a thread/started notification as an identity rebind. if (event.method === "thread/started" && thread.session?.status === "connecting") { return false; } return true;} function durationMs(startIso: string, endIso: string): number | undefined { const start = Date.parse(startIso); const end = Date.parse(endIso); if (Number.isNaN(start) || Number.isNaN(end) || end < start) { return undefined; } return end - start;} function updateTurnFields(thread: Thread, event: ProviderEvent): Partial<Thread> { if (event.method === "turn/started") { return { latestTurnId: getEventTurnId(event) ?? thread.latestTurnId, latestTurnStartedAt: event.createdAt, latestTurnCompletedAt: undefined, latestTurnDurationMs: undefined, }; } if (event.method === "turn/completed") { const completedTurnId = getEventTurnId(event) ?? thread.latestTurnId; const startedAt = completedTurnId && completedTurnId === thread.latestTurnId ? thread.latestTurnStartedAt : undefined; const elapsed = startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined; return { latestTurnId: completedTurnId ?? thread.latestTurnId, latestTurnCompletedAt: event.createdAt, latestTurnDurationMs: elapsed, }; } return {};} // ── Reducer ────────────────────────────────────────────────────────── export function reducer(state: AppState, action: Action): AppState { switch (action.type) { case "ADD_PROJECT": if (state.projects.some((project) => project.cwd === action.project.cwd)) { return state; } return { ...state, projects: [ ...state.projects, { ...action.project, model: resolveModelSlug(action.project.model), }, ], }; case "SYNC_PROJECTS": { const previousByCwd = new Map( state.projects.map((project) => [project.cwd, project] as const), ); const nextProjects = action.projects.map((project) => { const previous = previousByCwd.get(project.cwd); return { ...project, model: resolveModelSlug(previous?.model ?? project.model), expanded: previous?.expanded ?? project.expanded, }; }); const previousProjectById = new Map( state.projects.map((project) => [project.id, project] as const), ); const nextProjectIdByCwd = new Map( nextProjects.map((project) => [project.cwd, project.id] as const), ); const nextThreads = state.threads .map((thread) => { const previousProject = previousProjectById.get(thread.projectId); if (!previousProject) return null; const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd); if (!mappedProjectId) return null; return normalizeThreadTerminals({ ...thread, projectId: mappedProjectId, }); }) .filter((thread): thread is Thread => thread !== null); const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId) ? state.activeThreadId : (nextThreads[0]?.id ?? null); return { ...state, projects: nextProjects, threads: nextThreads, activeThreadId, }; } case "TOGGLE_PROJECT": return { ...state, projects: state.projects.map((p) => p.id === action.projectId ? { ...p, expanded: !p.expanded } : p, ), }; case "ADD_THREAD": { const nextThread = normalizeThreadTerminals({ ...action.thread, model: resolveModelSlug(action.thread.model), lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, }); return { ...state, threads: [...state.threads, nextThread], activeThreadId: action.thread.id, }; } case "SET_ACTIVE_THREAD": { const visitedAt = new Date().toISOString(); return { ...state, activeThreadId: action.threadId, threads: updateThread(state.threads, action.threadId, (t) => ({ ...t, lastVisitedAt: visitedAt, })), }; } Lines 1–500 of 836. Load more
import { type Dispatch, type ReactNode, createContext, createElement, useContext, useEffect, useReducer, } from "react"; import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts"; import { resolveModelSlug } from "./model-logic"; import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; import { type ChatAttachment, DEFAULT_THREAD_TERMINAL_ID, DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread, type ThreadTerminalGroup, } from "./types"; // ── Actions ────────────────────────────────────────────────────────── type Action = | { type: "ADD_PROJECT"; project: Project } | { type: "SYNC_PROJECTS"; projects: Project[] } | { type: "TOGGLE_PROJECT"; projectId: string } | { type: "ADD_THREAD"; thread: Thread } | { type: "SET_ACTIVE_THREAD"; threadId: string } | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "TOGGLE_DIFF" } | { type: "APPLY_EVENT"; event: ProviderEvent; activeAssistantItemRef: { current: string | null }; } | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string; attachments?: ChatAttachment[]; } | { type: "SET_ERROR"; threadId: string; error: string | null } | { type: "SET_THREAD_TITLE"; threadId: string; title: string } | { type: "SET_THREAD_MODEL"; threadId: string; model: string } | { type: "SET_THREAD_BRANCH"; threadId: string; branch: string | null; worktreePath: string | null; } | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode } | { type: "DELETE_THREAD"; threadId: string }; // ── State ──────────────────────────────────────────────────────────── export interface AppState { projects: Project[]; threads: Thread[]; activeThreadId: string | null; runtimeMode: RuntimeMode; diffOpen: boolean; } const PERSISTED_STATE_KEY = "t3code:renderer-state:v7"; const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v6", "t3code:renderer-state:v5", "t3code:renderer-state:v4", "t3code:renderer-state:v3", "codething:renderer-state:v4", "codething:renderer-state:v3", "codething:renderer-state:v2", "codething:renderer-state:v1", ] as const; const initialState: AppState = { projects: [], threads: [], activeThreadId: null, runtimeMode: DEFAULT_RUNTIME_MODE, diffOpen: false, }; // ── Helpers ────────────────────────────────────────────────────────── function readPersistedState(): AppState { if (typeof window === "undefined") return initialState; try { const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY); const legacyValues = LEGACY_PERSISTED_STATE_KEYS.map((key) => window.localStorage.getItem(key)); const rawLegacy = legacyValues.find((value) => value !== null) ?? null; const raw = rawCurrent ?? rawLegacy; if (!raw) return initialState; const rawCodethingV1 = window.localStorage.getItem("codething:renderer-state:v1"); const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1); if (!hydrated) return initialState; return { ...hydrated, diffOpen: false }; } catch { return initialState; } } function persistState(state: AppState): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state))); for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { window.localStorage.removeItem(legacyKey); } } catch { // Ignore quota/storage errors to avoid breaking chat UX. } } function updateThread( threads: Thread[], threadId: string, updater: (t: Thread) => Thread, ): Thread[] { return threads.map((t) => (t.id === threadId ? updater(t) : t)); } function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); const unique = [...new Set(ids)]; 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>): 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<string>(); const usedGroupIds = new Set<string>(); 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 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 findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { return threads.find((t) => t.session?.sessionId === sessionId); } function getEventTurnId(event: ProviderEvent): string | undefined { if (event.turnId) return event.turnId; const payload = asObject(event.payload); const turn = asObject(payload?.turn); return asString(turn?.id); } function getEventThreadId(event: ProviderEvent): string | undefined { if (event.threadId) return event.threadId; const payload = asObject(event.payload); const payloadThread = asObject(payload?.thread); const payloadMessage = asObject(payload?.msg); return ( asString(payload?.threadId) ?? asString(payloadThread?.id) ?? asString(payload?.conversationId) ?? asString(payload?.thread_id) ?? asString(payloadMessage?.thread_id) ); } function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean { const eventThreadId = getEventThreadId(event); if (!eventThreadId) { return false; } const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId; if (!expectedThreadId || eventThreadId === expectedThreadId) { return false; } // During connect, accept a thread/started notification as an identity rebind. if (event.method === "thread/started" && thread.session?.status === "connecting") { return false; } return true; } function durationMs(startIso: string, endIso: string): number | undefined { const start = Date.parse(startIso); const end = Date.parse(endIso); if (Number.isNaN(start) || Number.isNaN(end) || end < start) { return undefined; } return end - start; } function updateTurnFields(thread: Thread, event: ProviderEvent): Partial<Thread> { if (event.method === "turn/started") { return { latestTurnId: getEventTurnId(event) ?? thread.latestTurnId, latestTurnStartedAt: event.createdAt, latestTurnCompletedAt: undefined, latestTurnDurationMs: undefined, }; } if (event.method === "turn/completed") { const completedTurnId = getEventTurnId(event) ?? thread.latestTurnId; const startedAt = completedTurnId && completedTurnId === thread.latestTurnId ? thread.latestTurnStartedAt : undefined; const elapsed = startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined; return { latestTurnId: completedTurnId ?? thread.latestTurnId, latestTurnCompletedAt: event.createdAt, latestTurnDurationMs: elapsed, }; } return {}; } // ── Reducer ────────────────────────────────────────────────────────── export function reducer(state: AppState, action: Action): AppState { switch (action.type) { case "ADD_PROJECT": if (state.projects.some((project) => project.cwd === action.project.cwd)) { return state; } return { ...state, projects: [ ...state.projects, { ...action.project, model: resolveModelSlug(action.project.model), }, ], }; case "SYNC_PROJECTS": { const previousByCwd = new Map( state.projects.map((project) => [project.cwd, project] as const), ); const nextProjects = action.projects.map((project) => { const previous = previousByCwd.get(project.cwd); return { ...project, model: resolveModelSlug(previous?.model ?? project.model), expanded: previous?.expanded ?? project.expanded, }; }); const previousProjectById = new Map( state.projects.map((project) => [project.id, project] as const), ); const nextProjectIdByCwd = new Map( nextProjects.map((project) => [project.cwd, project.id] as const), ); const nextThreads = state.threads .map((thread) => { const previousProject = previousProjectById.get(thread.projectId); if (!previousProject) return null; const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd); if (!mappedProjectId) return null; return normalizeThreadTerminals({ ...thread, projectId: mappedProjectId, }); }) .filter((thread): thread is Thread => thread !== null); const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId) ? state.activeThreadId : (nextThreads[0]?.id ?? null); return { ...state, projects: nextProjects, threads: nextThreads, activeThreadId, }; } case "TOGGLE_PROJECT": return { ...state, projects: state.projects.map((p) => p.id === action.projectId ? { ...p, expanded: !p.expanded } : p, ), }; case "ADD_THREAD": { const nextThread = normalizeThreadTerminals({ ...action.thread, model: resolveModelSlug(action.thread.model), lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, }); return { ...state, threads: [...state.threads, nextThread], activeThreadId: action.thread.id, }; } case "SET_ACTIVE_THREAD": { const visitedAt = new Date().toISOString(); return { ...state, activeThreadId: action.threadId, threads: updateThread(state.threads, action.threadId, (t) => ({ ...t, lastVisitedAt: visitedAt, })), }; }