authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-07 21:36:13-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-07 21:36:13-08:00
log733c4633bfafd9b96208f8f39480b5f7ccb49677
tree702413622443773a808f86ddbfb8798caccbdfd7
parent548cd113b25e9e839e094c08cb4ce96366a9cb8b
signature Commit is signed but in an unrecognized format.

bugfix set 1


2 files changed, 198 insertions(+), 15 deletions(-)

apps/renderer/src/components/ChatView.tsx+8-13
...@@ -127,10 +127,11 @@ export default function ChatView() {...@@ -127,10 +127,11 @@ export default function ChatView() {
127 };127 };
128 }, [api, activeProject?.cwd, state.activeThreadId]);128 }, [api, activeProject?.cwd, state.activeThreadId]);
129129
130 const ensureSession = async (): Promise<boolean> => {130 const ensureSession = async (): Promise<string | null> => {
131 if (!api || !activeThread || !activeProject) return false;131 if (!api || !activeThread || !activeProject) return null;
132 if (activeThread.session && activeThread.session.status !== "closed")132 if (activeThread.session && activeThread.session.status !== "closed") {
133 return true;133 return activeThread.session.sessionId;
134 }
134135
135 setIsConnecting(true);136 setIsConnecting(true);
136 try {137 try {
...@@ -144,14 +145,14 @@ export default function ChatView() {...@@ -144,14 +145,14 @@ export default function ChatView() {
144 threadId: activeThread.id,145 threadId: activeThread.id,
145 session,146 session,
146 });147 });
147 return true;148 return session.sessionId;
148 } catch (err) {149 } catch (err) {
149 dispatch({150 dispatch({
150 type: "SET_ERROR",151 type: "SET_ERROR",
151 threadId: activeThread.id,152 threadId: activeThread.id,
152 error: err instanceof Error ? err.message : "Failed to connect.",153 error: err instanceof Error ? err.message : "Failed to connect.",
153 });154 });
154 return false;155 return null;
155 } finally {156 } finally {
156 setIsConnecting(false);157 setIsConnecting(false);
157 }158 }
...@@ -187,13 +188,7 @@ export default function ChatView() {...@@ -187,13 +188,7 @@ export default function ChatView() {
187 });188 });
188 setPrompt("");189 setPrompt("");
189190
190 const connected = await ensureSession();191 const sessionId = await ensureSession();
191 if (!connected) return;
192
193 // Re-read thread to get session after potential connection
194 const updatedThread = state.threads.find((t) => t.id === activeThread.id);
195 const sessionId =
196 updatedThread?.session?.sessionId ?? activeThread.session?.sessionId;
197 if (!sessionId) return;192 if (!sessionId) return;
198193
199 setIsSending(true);194 setIsSending(true);
apps/renderer/src/store.ts+190-2
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 createContext,4 createContext,
5 createElement,5 createElement,
6 useContext,6 useContext,
7 useEffect,
7 useReducer,8 useReducer,
8} from "react";9} from "react";
910
...@@ -39,6 +40,8 @@ export interface AppState {...@@ -39,6 +40,8 @@ export interface AppState {
39 diffOpen: boolean;40 diffOpen: boolean;
40}41}
4142
43const PERSISTED_STATE_KEY = "codething:renderer-state:v1";
44
42const initialState: AppState = {45const initialState: AppState = {
43 projects: [],46 projects: [],
44 threads: [],47 threads: [],
...@@ -48,6 +51,187 @@ const initialState: AppState = {...@@ -48,6 +51,187 @@ const initialState: AppState = {
4851
49// ── Helpers ──────────────────────────────────────────────────────────52// ── Helpers ──────────────────────────────────────────────────────────
5053
54interface PersistedStateV1 {
55 projects: Project[];
56 threads: Array<
57 Pick<
58 Thread,
59 "id" | "projectId" | "title" | "model" | "messages" | "createdAt"
60 >
61 >;
62 activeThreadId: string | null;
63}
64
65function asObject(value: unknown): Record<string, unknown> | null {
66 if (!value || typeof value !== "object") return null;
67 return value as Record<string, unknown>;
68}
69
70function asString(value: unknown): string | null {
71 return typeof value === "string" ? value : null;
72}
73
74function isBoolean(value: unknown): value is boolean {
75 return typeof value === "boolean";
76}
77
78function sanitizeProjects(input: unknown): Project[] {
79 if (!Array.isArray(input)) return [];
80
81 return input
82 .map((raw) => {
83 const project = asObject(raw);
84 if (!project) return null;
85
86 const id = asString(project.id);
87 const name = asString(project.name);
88 const cwd = asString(project.cwd);
89 const model = asString(project.model);
90 const expanded = project.expanded;
91 if (!id || !name || !cwd || !model || !isBoolean(expanded)) return null;
92
93 return {
94 id,
95 name,
96 cwd,
97 model,
98 expanded,
99 } satisfies Project;
100 })
101 .filter((project): project is Project => project !== null);
102}
103
104function sanitizeMessages(input: unknown): Thread["messages"] {
105 if (!Array.isArray(input)) return [];
106
107 return input
108 .map((raw) => {
109 const message = asObject(raw);
110 if (!message) return null;
111
112 const id = asString(message.id);
113 const role = message.role;
114 const text = asString(message.text);
115 const createdAt = asString(message.createdAt);
116 const streaming = message.streaming;
117 if (
118 !id ||
119 (role !== "user" && role !== "assistant") ||
120 text === null ||
121 !createdAt ||
122 !isBoolean(streaming)
123 ) {
124 return null;
125 }
126
127 const hydratedMessage: Thread["messages"][number] = {
128 id,
129 role,
130 text,
131 createdAt,
132 streaming: false,
133 };
134 return hydratedMessage;
135 })
136 .filter(
137 (message): message is Thread["messages"][number] => message !== null,
138 );
139}
140
141function sanitizeThreads(input: unknown): AppState["threads"] {
142 if (!Array.isArray(input)) return [];
143
144 return input
145 .map((raw) => {
146 const thread = asObject(raw);
147 if (!thread) return null;
148
149 const id = asString(thread.id);
150 const projectId = asString(thread.projectId);
151 const title = asString(thread.title);
152 const model = asString(thread.model);
153 const createdAt = asString(thread.createdAt);
154 if (!id || !projectId || !title || !model || !createdAt) return null;
155
156 const hydratedThread: Thread = {
157 id,
158 projectId,
159 title,
160 model,
161 session: null,
162 messages: sanitizeMessages(thread.messages),
163 events: [],
164 error: null,
165 createdAt,
166 };
167 return hydratedThread;
168 })
169 .filter((thread): thread is Thread => thread !== null);
170}
171
172function readPersistedState(): AppState {
173 if (typeof window === "undefined") return initialState;
174
175 try {
176 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
177 if (!raw) return initialState;
178
179 const parsed = asObject(JSON.parse(raw));
180 if (!parsed) return initialState;
181
182 const projects = sanitizeProjects(parsed.projects);
183 const projectIds = new Set(projects.map((project) => project.id));
184 const threads = sanitizeThreads(parsed.threads).filter((thread) =>
185 projectIds.has(thread.projectId),
186 );
187 const activeThreadId = asString(parsed.activeThreadId);
188 const hasActiveThread = Boolean(
189 activeThreadId && threads.some((thread) => thread.id === activeThreadId),
190 );
191
192 return {
193 projects,
194 threads,
195 activeThreadId: hasActiveThread
196 ? activeThreadId
197 : threads[0]?.id
198 ? threads[0].id
199 : null,
200 diffOpen: false,
201 };
202 } catch {
203 return initialState;
204 }
205}
206
207function toPersistedState(state: AppState): PersistedStateV1 {
208 return {
209 projects: state.projects,
210 threads: state.threads.map((thread) => ({
211 id: thread.id,
212 projectId: thread.projectId,
213 title: thread.title,
214 model: thread.model,
215 messages: thread.messages,
216 createdAt: thread.createdAt,
217 })),
218 activeThreadId: state.activeThreadId,
219 };
220}
221
222function persistState(state: AppState): void {
223 if (typeof window === "undefined") return;
224
225 try {
226 window.localStorage.setItem(
227 PERSISTED_STATE_KEY,
228 JSON.stringify(toPersistedState(state)),
229 );
230 } catch {
231 // Ignore quota/storage errors to avoid breaking chat UX.
232 }
233}
234
51function updateThread(235function updateThread(
52 threads: Thread[],236 threads: Thread[],
53 threadId: string,237 threadId: string,
...@@ -119,7 +303,6 @@ function reducer(state: AppState, action: Action): AppState {...@@ -119,7 +303,6 @@ function reducer(state: AppState, action: Action): AppState {
119 threads: updateThread(state.threads, action.threadId, (t) => ({303 threads: updateThread(state.threads, action.threadId, (t) => ({
120 ...t,304 ...t,
121 session: action.session,305 session: action.session,
122 messages: [],
123 events: [],306 events: [],
124 error: null,307 error: null,
125 })),308 })),
...@@ -183,7 +366,12 @@ const StoreContext = createContext<{...@@ -183,7 +366,12 @@ const StoreContext = createContext<{
183}>({ state: initialState, dispatch: () => {} });366}>({ state: initialState, dispatch: () => {} });
184367
185export function StoreProvider({ children }: { children: ReactNode }) {368export function StoreProvider({ children }: { children: ReactNode }) {
186 const [state, dispatch] = useReducer(reducer, initialState);369 const [state, dispatch] = useReducer(reducer, undefined, readPersistedState);
370
371 useEffect(() => {
372 persistState(state);
373 }, [state]);
374
187 return createElement(375 return createElement(
188 StoreContext.Provider,376 StoreContext.Provider,
189 { value: { state, dispatch } },377 { value: { state, dispatch } },