import { Fragment, type ReactNode, createElement, useEffect } from "react"; import { DEFAULT_MODEL, type ProviderKind, ThreadId, type OrchestrationReadModel, type OrchestrationSessionStatus, } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug, resolveModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; import { create } from "zustand"; import { type ChatMessage, type Project, type Thread, } from "./types"; // ── State ──────────────────────────────────────────────────────────── export interface AppState { projects: Project[]; threads: Thread[]; threadsHydrated: boolean; } const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; 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: [], threadsHydrated: false, }; const persistedExpandedProjectCwds = new Set(); // ── Persist helpers ────────────────────────────────────────────────── function readPersistedState(): AppState { if (typeof window === "undefined") return initialState; try { const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); if (!raw) return initialState; const parsed = JSON.parse(raw) as { expandedProjectCwds?: string[] }; persistedExpandedProjectCwds.clear(); for (const cwd of parsed.expandedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedExpandedProjectCwds.add(cwd); } } return { ...initialState }; } catch { return initialState; } } function persistState(state: AppState): void { if (typeof window === "undefined") return; try { window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ expandedProjectCwds: state.projects .filter((project) => project.expanded) .map((project) => project.cwd), }), ); for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { window.localStorage.removeItem(legacyKey); } } catch { // Ignore quota/storage errors to avoid breaking chat UX. } } // ── Pure helpers ────────────────────────────────────────────────────── function updateThread( threads: Thread[], threadId: ThreadId, updater: (t: Thread) => Thread, ): Thread[] { let changed = false; const next = threads.map((t) => { if (t.id !== threadId) return t; const updated = updater(t); if (updated !== t) changed = true; return updated; }); return changed ? next : threads; } function mapProjectsFromReadModel( incoming: OrchestrationReadModel["projects"], previous: Project[], ): Project[] { return incoming.map((project) => { const existing = previous.find((entry) => entry.id === project.id) ?? previous.find((entry) => entry.cwd === project.workspaceRoot); return { id: project.id, name: project.title, cwd: project.workspaceRoot, model: existing?.model ?? resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL), expanded: existing?.expanded ?? (persistedExpandedProjectCwds.size > 0 ? persistedExpandedProjectCwds.has(project.workspaceRoot) : true), scripts: project.scripts.map((script) => ({ ...script })), }; }); } function toLegacySessionStatus( status: OrchestrationSessionStatus, ): "connecting" | "ready" | "running" | "error" | "closed" { switch (status) { case "starting": return "connecting"; case "running": return "running"; case "error": return "error"; case "ready": case "interrupted": return "ready"; case "idle": case "stopped": return "closed"; } } function toLegacyProvider(providerName: string | null): ProviderKind { if (providerName === "codex" || providerName === "claudeCode" || providerName === "cursor") { return providerName; } return "codex"; } const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); const CLAUDE_MODEL_SLUGS = new Set(getModelOptions("claudeCode").map((option) => option.slug)); const CURSOR_MODEL_SLUGS = new Set(getModelOptions("cursor").map((option) => option.slug)); const CURSOR_DISTINCT_MODEL_SLUGS = new Set( [...CURSOR_MODEL_SLUGS].filter( (slug) => !CODEX_MODEL_SLUGS.has(slug) && !CLAUDE_MODEL_SLUGS.has(slug), ), ); function inferProviderForThreadModel(input: { readonly model: string; readonly sessionProviderName: string | null; }): ProviderKind { if ( input.sessionProviderName === "codex" || input.sessionProviderName === "claudeCode" || input.sessionProviderName === "cursor" ) { return input.sessionProviderName; } const normalizedCursor = normalizeModelSlug(input.model, "cursor"); if (normalizedCursor && CURSOR_DISTINCT_MODEL_SLUGS.has(normalizedCursor)) { return "cursor"; } const normalizedClaude = normalizeModelSlug(input.model, "claudeCode"); if (normalizedClaude && CLAUDE_MODEL_SLUGS.has(normalizedClaude)) { return "claudeCode"; } const normalizedCodex = normalizeModelSlug(input.model, "codex"); if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { return "codex"; } if ( input.model.trim().startsWith("composer-") || input.model.trim().startsWith("gemini-") || input.model.trim().endsWith("-thinking") ) { return "cursor"; } return input.model.trim().startsWith("claude-") ? "claudeCode" : "codex"; } function resolveWsHttpOrigin(): string { if (typeof window === "undefined") return ""; const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; const wsCandidate = typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0 ? bridgeWsUrl : typeof envWsUrl === "string" && envWsUrl.length > 0 ? envWsUrl : null; if (!wsCandidate) return window.location.origin; try { const wsUrl = new URL(wsCandidate); const protocol = wsUrl.protocol === "wss:" ? "https:" : wsUrl.protocol === "ws:" ? "http:" : wsUrl.protocol; return `${protocol}//${wsUrl.host}`; } catch { return window.location.origin; } } function toAttachmentPreviewUrl(rawUrl: string): string { if (rawUrl.startsWith("/")) { return `${resolveWsHttpOrigin()}${rawUrl}`; } return rawUrl; } function attachmentPreviewRoutePath(attachmentId: string): string { return `/attachments/${encodeURIComponent(attachmentId)}`; } // ── Pure state transition functions ──────────────────────────────────── export function syncServerReadModel( state: AppState, readModel: OrchestrationReadModel, ): AppState { const projects = mapProjectsFromReadModel( readModel.projects.filter((project) => project.deletedAt === null), state.projects, ); const existingThreadById = new Map( state.threads.map((thread) => [thread.id, thread] as const), ); const threads = readModel.threads .filter((thread) => thread.deletedAt === null) .map((thread) => { const existing = existingThreadById.get(thread.id); return { id: thread.id, codexThreadId: null, projectId: thread.projectId, title: thread.title, model: resolveModelSlugForProvider( inferProviderForThreadModel({ model: thread.model, sessionProviderName: thread.session?.providerName ?? null, }), thread.model, ), runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, session: thread.session ? { provider: toLegacyProvider(thread.session.providerName), status: toLegacySessionStatus(thread.session.status), orchestrationStatus: thread.session.status, activeTurnId: thread.session.activeTurnId ?? undefined, createdAt: thread.session.updatedAt, updatedAt: thread.session.updatedAt, ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), } : null, messages: thread.messages.map((message) => { const attachments = message.attachments?.map((attachment) => ({ type: "image" as const, id: attachment.id, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), })); 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 } : {}), }; return normalizedMessage; }), proposedPlans: thread.proposedPlans.map((proposedPlan) => ({ id: proposedPlan.id, turnId: proposedPlan.turnId, planMarkdown: proposedPlan.planMarkdown, createdAt: proposedPlan.createdAt, updatedAt: proposedPlan.updatedAt, })), error: thread.session?.lastError ?? null, createdAt: thread.createdAt, latestTurn: thread.latestTurn, lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ turnId: checkpoint.turnId, completedAt: checkpoint.completedAt, status: checkpoint.status, assistantMessageId: checkpoint.assistantMessageId ?? undefined, checkpointTurnCount: checkpoint.checkpointTurnCount, checkpointRef: checkpoint.checkpointRef, files: checkpoint.files.map((file) => ({ ...file })), })), activities: thread.activities.map((activity) => ({ ...activity })), }; }); return { ...state, projects, threads, threadsHydrated: true, }; } export function markThreadVisited( state: AppState, threadId: ThreadId, visitedAt?: string, ): AppState { const at = visitedAt ?? new Date().toISOString(); const visitedAtMs = Date.parse(at); const threads = updateThread(state.threads, threadId, (thread) => { const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; if ( Number.isFinite(previousVisitedAtMs) && Number.isFinite(visitedAtMs) && previousVisitedAtMs >= visitedAtMs ) { return thread; } return { ...thread, lastVisitedAt: at }; }); return threads === state.threads ? state : { ...state, threads }; } export function markThreadUnread(state: AppState, threadId: ThreadId): AppState { const threads = updateThread(state.threads, threadId, (thread) => { if (!thread.latestTurn?.completedAt) return thread; const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); if (Number.isNaN(latestTurnCompletedAtMs)) return thread; const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); if (thread.lastVisitedAt === unreadVisitedAt) return thread; return { ...thread, lastVisitedAt: unreadVisitedAt }; }); return threads === state.threads ? state : { ...state, threads }; } export function toggleProject(state: AppState, projectId: Project["id"]): AppState { return { ...state, projects: state.projects.map((p) => p.id === projectId ? { ...p, expanded: !p.expanded } : p, ), }; } export function setProjectExpanded( state: AppState, projectId: Project["id"], expanded: boolean, ): AppState { let changed = false; const projects = state.projects.map((p) => { if (p.id !== projectId || p.expanded === expanded) return p; changed = true; return { ...p, expanded }; }); return changed ? { ...state, projects } : state; } export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { const threads = updateThread(state.threads, threadId, (t) => { if (t.error === error) return t; return { ...t, error }; }); return threads === state.threads ? state : { ...state, threads }; } export function setThreadBranch( state: AppState, threadId: ThreadId, branch: string | null, worktreePath: string | null, ): AppState { const threads = updateThread(state.threads, threadId, (t) => { if (t.branch === branch && t.worktreePath === worktreePath) return t; const cwdChanged = t.worktreePath !== worktreePath; return { ...t, branch, worktreePath, ...(cwdChanged ? { session: null } : {}), }; }); return threads === state.threads ? state : { ...state, threads }; } // ── Zustand store ──────────────────────────────────────────────────── interface AppStore extends AppState { syncServerReadModel: (readModel: OrchestrationReadModel) => void; markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; markThreadUnread: (threadId: ThreadId) => void; toggleProject: (projectId: Project["id"]) => void; setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: ( threadId: ThreadId, branch: string | null, worktreePath: string | null, ) => void; } export const useStore = create((set) => ({ ...readPersistedState(), syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId) => set((state) => markThreadUnread(state, threadId)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), })); // Persist on every state change useStore.subscribe((state) => persistState(state)); export function StoreProvider({ children }: { children: ReactNode }) { useEffect(() => { persistState(useStore.getState()); }, []); return createElement(Fragment, null, children); }