From 733c4633bfafd9b96208f8f39480b5f7ccb49677 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 7 Feb 2026 21:36:13 -0800 Subject: [PATCH] bugfix set 1 --- apps/renderer/src/components/ChatView.tsx | 21 +-- apps/renderer/src/store.ts | 192 +++++++++++++++++++++- 2 files changed, 198 insertions(+), 15 deletions(-) diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx index d76ea9316ff2e3c4ee53db77395d79c9fb6a5a46..27b51ba9496189b965db1f006f67325a231e6a71 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -127,10 +127,11 @@ export default function ChatView() { }; }, [api, activeProject?.cwd, state.activeThreadId]); - const ensureSession = async (): Promise => { - if (!api || !activeThread || !activeProject) return false; - if (activeThread.session && activeThread.session.status !== "closed") - return true; + const ensureSession = async (): Promise => { + if (!api || !activeThread || !activeProject) return null; + if (activeThread.session && activeThread.session.status !== "closed") { + return activeThread.session.sessionId; + } setIsConnecting(true); try { @@ -144,14 +145,14 @@ export default function ChatView() { threadId: activeThread.id, session, }); - return true; + return session.sessionId; } catch (err) { dispatch({ type: "SET_ERROR", threadId: activeThread.id, error: err instanceof Error ? err.message : "Failed to connect.", }); - return false; + return null; } finally { setIsConnecting(false); } @@ -187,13 +188,7 @@ export default function ChatView() { }); setPrompt(""); - const connected = await ensureSession(); - if (!connected) return; - - // Re-read thread to get session after potential connection - const updatedThread = state.threads.find((t) => t.id === activeThread.id); - const sessionId = - updatedThread?.session?.sessionId ?? activeThread.session?.sessionId; + const sessionId = await ensureSession(); if (!sessionId) return; setIsSending(true); diff --git a/apps/renderer/src/store.ts b/apps/renderer/src/store.ts index 2686ff4f2b41863489a0b9b5b30e7a496bd777fc..9adbf14768aa25bdc555705e17fe5ccd34948f13 100644 --- a/apps/renderer/src/store.ts +++ b/apps/renderer/src/store.ts @@ -4,6 +4,7 @@ import { createContext, createElement, useContext, + useEffect, useReducer, } from "react"; @@ -39,6 +40,8 @@ export interface AppState { diffOpen: boolean; } +const PERSISTED_STATE_KEY = "codething:renderer-state:v1"; + const initialState: AppState = { projects: [], threads: [], @@ -48,6 +51,187 @@ const initialState: AppState = { // ── Helpers ────────────────────────────────────────────────────────── +interface PersistedStateV1 { + projects: Project[]; + threads: Array< + Pick< + Thread, + "id" | "projectId" | "title" | "model" | "messages" | "createdAt" + > + >; + activeThreadId: string | null; +} + +function asObject(value: unknown): Record | null { + if (!value || typeof value !== "object") return null; + return value as Record; +} + +function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function isBoolean(value: unknown): value is boolean { + return typeof value === "boolean"; +} + +function sanitizeProjects(input: unknown): Project[] { + if (!Array.isArray(input)) return []; + + return input + .map((raw) => { + const project = asObject(raw); + if (!project) return null; + + const id = asString(project.id); + const name = asString(project.name); + const cwd = asString(project.cwd); + const model = asString(project.model); + const expanded = project.expanded; + if (!id || !name || !cwd || !model || !isBoolean(expanded)) return null; + + return { + id, + name, + cwd, + model, + expanded, + } satisfies Project; + }) + .filter((project): project is Project => project !== null); +} + +function sanitizeMessages(input: unknown): Thread["messages"] { + if (!Array.isArray(input)) return []; + + return input + .map((raw) => { + const message = asObject(raw); + if (!message) return null; + + const id = asString(message.id); + const role = message.role; + const text = asString(message.text); + const createdAt = asString(message.createdAt); + const streaming = message.streaming; + if ( + !id || + (role !== "user" && role !== "assistant") || + text === null || + !createdAt || + !isBoolean(streaming) + ) { + return null; + } + + const hydratedMessage: Thread["messages"][number] = { + id, + role, + text, + createdAt, + streaming: false, + }; + return hydratedMessage; + }) + .filter( + (message): message is Thread["messages"][number] => message !== null, + ); +} + +function sanitizeThreads(input: unknown): AppState["threads"] { + if (!Array.isArray(input)) return []; + + return input + .map((raw) => { + const thread = asObject(raw); + if (!thread) return null; + + const id = asString(thread.id); + const projectId = asString(thread.projectId); + const title = asString(thread.title); + const model = asString(thread.model); + const createdAt = asString(thread.createdAt); + if (!id || !projectId || !title || !model || !createdAt) return null; + + const hydratedThread: Thread = { + id, + projectId, + title, + model, + session: null, + messages: sanitizeMessages(thread.messages), + events: [], + error: null, + createdAt, + }; + return hydratedThread; + }) + .filter((thread): thread is Thread => thread !== null); +} + +function readPersistedState(): AppState { + if (typeof window === "undefined") return initialState; + + try { + const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); + if (!raw) return initialState; + + const parsed = asObject(JSON.parse(raw)); + if (!parsed) return initialState; + + const projects = sanitizeProjects(parsed.projects); + const projectIds = new Set(projects.map((project) => project.id)); + const threads = sanitizeThreads(parsed.threads).filter((thread) => + projectIds.has(thread.projectId), + ); + const activeThreadId = asString(parsed.activeThreadId); + const hasActiveThread = Boolean( + activeThreadId && threads.some((thread) => thread.id === activeThreadId), + ); + + return { + projects, + threads, + activeThreadId: hasActiveThread + ? activeThreadId + : threads[0]?.id + ? threads[0].id + : null, + diffOpen: false, + }; + } catch { + return initialState; + } +} + +function toPersistedState(state: AppState): PersistedStateV1 { + return { + projects: state.projects, + threads: state.threads.map((thread) => ({ + id: thread.id, + projectId: thread.projectId, + title: thread.title, + model: thread.model, + messages: thread.messages, + createdAt: thread.createdAt, + })), + activeThreadId: state.activeThreadId, + }; +} + +function persistState(state: AppState): void { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem( + PERSISTED_STATE_KEY, + JSON.stringify(toPersistedState(state)), + ); + } catch { + // Ignore quota/storage errors to avoid breaking chat UX. + } +} + function updateThread( threads: Thread[], threadId: string, @@ -119,7 +303,6 @@ function reducer(state: AppState, action: Action): AppState { threads: updateThread(state.threads, action.threadId, (t) => ({ ...t, session: action.session, - messages: [], events: [], error: null, })), @@ -183,7 +366,12 @@ const StoreContext = createContext<{ }>({ state: initialState, dispatch: () => {} }); export function StoreProvider({ children }: { children: ReactNode }) { - const [state, dispatch] = useReducer(reducer, initialState); + const [state, dispatch] = useReducer(reducer, undefined, readPersistedState); + + useEffect(() => { + persistState(state); + }, [state]); + return createElement( StoreContext.Provider, { value: { state, dispatch } }, -- 2.54.0