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() {
127127 };
128128 }, [api, activeProject?.cwd, state.activeThreadId]);
129129
130 const ensureSession = async (): Promise<boolean> => {
131 if (!api || !activeThread || !activeProject) return false;
132 if (activeThread.session && activeThread.session.status !== "closed")
133 return true;
130 const ensureSession = async (): Promise<string | null> => {
131 if (!api || !activeThread || !activeProject) return null;
132 if (activeThread.session && activeThread.session.status !== "closed") {
133 return activeThread.session.sessionId;
134 }
134135
135136 setIsConnecting(true);
136137 try {
......@@ -144,14 +145,14 @@ export default function ChatView() {
144145 threadId: activeThread.id,
145146 session,
146147 });
147 return true;
148 return session.sessionId;
148149 } catch (err) {
149150 dispatch({
150151 type: "SET_ERROR",
151152 threadId: activeThread.id,
152153 error: err instanceof Error ? err.message : "Failed to connect.",
153154 });
154 return false;
155 return null;
155156 } finally {
156157 setIsConnecting(false);
157158 }
......@@ -187,13 +188,7 @@ export default function ChatView() {
187188 });
188189 setPrompt("");
189190
190 const connected = 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;
191 const sessionId = await ensureSession();
197192 if (!sessionId) return;
198193
199194 setIsSending(true);
apps/renderer/src/store.ts+190-2
......@@ -4,6 +4,7 @@ import {
44 createContext,
55 createElement,
66 useContext,
7 useEffect,
78 useReducer,
89} from "react";
910
......@@ -39,6 +40,8 @@ export interface AppState {
3940 diffOpen: boolean;
4041}
4142
43const PERSISTED_STATE_KEY = "codething:renderer-state:v1";
44
4245const initialState: AppState = {
4346 projects: [],
4447 threads: [],
......@@ -48,6 +51,187 @@ const initialState: AppState = {
4851
4952// ── 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
51235function updateThread(
52236 threads: Thread[],
53237 threadId: string,
......@@ -119,7 +303,6 @@ function reducer(state: AppState, action: Action): AppState {
119303 threads: updateThread(state.threads, action.threadId, (t) => ({
120304 ...t,
121305 session: action.session,
122 messages: [],
123306 events: [],
124307 error: null,
125308 })),
......@@ -183,7 +366,12 @@ const StoreContext = createContext<{
183366}>({ state: initialState, dispatch: () => {} });
184367
185368export 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
187375 return createElement(
188376 StoreContext.Provider,
189377 { value: { state, dispatch } },