authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-07 20:19:55-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-07 20:19:55-08:00
log9ae4d50a98cdbeaf0f9978a86beb7dcb4d6a1d4d
treeb1cf92edfd2779d6b24f2a0ada41ec70b3eb231a
parentf733cb7e5f44344a1feb4dc82b18739548a9c9a4
signature Commit is signed but in an unrecognized format.

More redesign work


9 files changed, 1065 insertions(+), 740 deletions(-)

apps/desktop/scripts/smoke-test.mjs+2-2
......@@ -61,8 +61,8 @@ child.on("exit", () => {
6161 const fatalPatterns = [
6262 "Cannot find module",
6363 "MODULE_NOT_FOUND",
64 "Refused to execute", // CSP blocking scripts
65 "can't detect preamble", // @vitejs/plugin-react failure
64 "Refused to execute", // CSP blocking scripts
65 "can't detect preamble", // @vitejs/plugin-react failure
6666 "Uncaught Error",
6767 "Uncaught TypeError",
6868 "Uncaught ReferenceError",
apps/renderer/src/App.tsx+38-595
......@@ -1,617 +1,60 @@
1import {
2 type FormEvent,
3 type MutableRefObject,
4 useEffect,
5 useMemo,
6 useRef,
7 useState,
8} from "react";
1import { useEffect, useMemo, useRef } from "react";
92
10import type {
11 ProviderEvent,
12 ProviderKind,
13 ProviderSession,
14} from "@acme/contracts";
3import ChatView from "./components/ChatView";
4import DiffPanel from "./components/DiffPanel";
5import Sidebar from "./components/Sidebar";
6import { readNativeApi } from "./session-logic";
7import { StoreProvider, useStore } from "./store";
158
16type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
17
18interface ChatMessage {
19 id: string;
20 role: "user" | "assistant";
21 text: string;
22 createdAt: string;
23 streaming: boolean;
24}
25
26const PROVIDER_OPTIONS: Array<{
27 value: ProviderKind;
28 label: string;
29 available: boolean;
30}> = [
31 { value: "codex", label: "Codex", available: true },
32 { value: "claudeCode", label: "Claude Code (soon)", available: false },
33];
34
35function readNativeApi() {
36 if (typeof window === "undefined") {
37 return undefined;
38 }
39
40 return window.nativeApi;
41}
42
43function asObject(value: unknown): Record<string, unknown> | undefined {
44 if (!value || typeof value !== "object") {
45 return undefined;
46 }
47
48 return value as Record<string, unknown>;
49}
50
51function asString(value: unknown): string | undefined {
52 return typeof value === "string" ? value : undefined;
53}
54
55function formatTimestamp(isoDate: string): string {
56 return new Intl.DateTimeFormat(undefined, {
57 hour: "numeric",
58 minute: "2-digit",
59 second: "2-digit",
60 }).format(new Date(isoDate));
61}
62
63function derivePhase(session: ProviderSession | null): SessionPhase {
64 if (!session || session.status === "closed") {
65 return "disconnected";
66 }
67
68 if (session.status === "connecting") {
69 return "connecting";
70 }
71
72 if (session.status === "running") {
73 return "running";
74 }
75
76 return "ready";
77}
78
79function evolveSession(
80 previous: ProviderSession,
81 event: ProviderEvent,
82): ProviderSession {
83 const payload = asObject(event.payload);
84
85 if (event.method === "thread/started") {
86 const thread = asObject(payload?.thread);
87 return {
88 ...previous,
89 threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId,
90 updatedAt: event.createdAt,
91 };
92 }
93
94 if (event.method === "turn/started") {
95 const turn = asObject(payload?.turn);
96 return {
97 ...previous,
98 status: "running",
99 activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId,
100 updatedAt: event.createdAt,
101 };
102 }
103
104 if (event.method === "turn/completed") {
105 const turn = asObject(payload?.turn);
106 const status = asString(turn?.status);
107 const turnError = asObject(turn?.error);
108 return {
109 ...previous,
110 status: status === "failed" ? "error" : "ready",
111 activeTurnId: undefined,
112 lastError: asString(turnError?.message) ?? previous.lastError,
113 updatedAt: event.createdAt,
114 };
115 }
116
117 if (event.kind === "error") {
118 return {
119 ...previous,
120 status: "error",
121 lastError: event.message ?? previous.lastError,
122 updatedAt: event.createdAt,
123 };
124 }
125
126 if (event.method === "session/closed" || event.method === "session/exited") {
127 return {
128 ...previous,
129 status: "closed",
130 activeTurnId: undefined,
131 lastError: event.message ?? previous.lastError,
132 updatedAt: event.createdAt,
133 };
134 }
135
136 return {
137 ...previous,
138 updatedAt: event.createdAt,
139 };
140}
141
142function applyEventToMessages(
143 previous: ChatMessage[],
144 event: ProviderEvent,
145 activeAssistantItemRef: MutableRefObject<string | null>,
146): ChatMessage[] {
147 const payload = asObject(event.payload);
148
149 if (event.method === "item/started") {
150 const item = asObject(payload?.item);
151 if (asString(item?.type) !== "agentMessage") {
152 return previous;
153 }
154
155 const itemId = asString(item?.id);
156 if (!itemId) {
157 return previous;
158 }
159
160 activeAssistantItemRef.current = itemId;
161 const seedText = asString(item?.text) ?? "";
162 const filtered = previous.filter((entry) => entry.id !== itemId);
163 return [
164 ...filtered,
165 {
166 id: itemId,
167 role: "assistant",
168 text: seedText,
169 createdAt: event.createdAt,
170 streaming: true,
171 },
172 ];
173 }
174
175 if (event.method === "item/agentMessage/delta") {
176 const itemId = event.itemId ?? asString(payload?.itemId);
177 const delta = event.textDelta ?? asString(payload?.delta) ?? "";
178 if (!itemId || !delta) {
179 return previous;
180 }
181
182 const existingIndex = previous.findIndex((entry) => entry.id === itemId);
183 if (existingIndex === -1) {
184 activeAssistantItemRef.current = itemId;
185 return [
186 ...previous,
187 {
188 id: itemId,
189 role: "assistant",
190 text: delta,
191 createdAt: event.createdAt,
192 streaming: true,
193 },
194 ];
195 }
196
197 const updated = [...previous];
198 const existing = updated[existingIndex];
199 if (!existing) {
200 return previous;
201 }
202 updated[existingIndex] = {
203 ...existing,
204 text: `${existing.text}${delta}`,
205 streaming: true,
206 };
207 return updated;
208 }
209
210 if (event.method === "item/completed") {
211 const item = asObject(payload?.item);
212 if (asString(item?.type) !== "agentMessage") {
213 return previous;
214 }
215
216 const itemId = asString(item?.id);
217 if (!itemId) {
218 return previous;
219 }
220
221 const fullText = asString(item?.text);
222 const existingIndex = previous.findIndex((entry) => entry.id === itemId);
223 if (existingIndex === -1) {
224 return [
225 ...previous,
226 {
227 id: itemId,
228 role: "assistant",
229 text: fullText ?? "",
230 createdAt: event.createdAt,
231 streaming: false,
232 },
233 ];
234 }
235
236 const updated = [...previous];
237 const existing = updated[existingIndex];
238 if (!existing) {
239 return previous;
240 }
241 updated[existingIndex] = {
242 ...existing,
243 text: fullText ?? existing.text,
244 streaming: false,
245 };
246
247 if (activeAssistantItemRef.current === itemId) {
248 activeAssistantItemRef.current = null;
249 }
250
251 return updated;
252 }
253
254 if (event.method === "turn/completed") {
255 return previous.map((entry) => ({ ...entry, streaming: false }));
256 }
257
258 return previous;
259}
260
261export default function App() {
9function EventRouter() {
26210 const api = useMemo(() => readNativeApi(), []);
263 const [provider, setProvider] = useState<ProviderKind>("codex");
264 const [cwd, setCwd] = useState("");
265 const [model, setModel] = useState("gpt-5.1-codex");
266 const [prompt, setPrompt] = useState("");
267 const [session, setSession] = useState<ProviderSession | null>(null);
268 const [messages, setMessages] = useState<ChatMessage[]>([]);
269 const [events, setEvents] = useState<ProviderEvent[]>([]);
270 const [isConnecting, setIsConnecting] = useState(false);
271 const [isSending, setIsSending] = useState(false);
272 const [error, setError] = useState<string | null>(null);
273
274 const currentSessionIdRef = useRef<string | null>(null);
11 const { dispatch } = useStore();
27512 const activeAssistantItemRef = useRef<string | null>(null);
27613
27714 useEffect(() => {
278 currentSessionIdRef.current = session?.sessionId ?? null;
279 }, [session?.sessionId]);
280
281 useEffect(() => {
282 if (!api) {
283 return;
284 }
285
15 if (!api) return;
28616 return api.providers.onEvent((event) => {
287 const currentSessionId = currentSessionIdRef.current;
288 if (!currentSessionId || currentSessionId !== event.sessionId) {
289 return;
290 }
291
292 setEvents((previous) => [event, ...previous].slice(0, 200));
293 setSession((previous) => {
294 if (!previous || previous.sessionId !== event.sessionId) {
295 return previous;
296 }
297 return evolveSession(previous, event);
17 dispatch({
18 type: "APPLY_EVENT",
19 event,
20 activeAssistantItemRef,
29821 });
299 setMessages((previous) =>
300 applyEventToMessages(previous, event, activeAssistantItemRef),
301 );
302
303 if (event.kind === "error" && event.message) {
304 setError(event.message);
305 }
30622 });
307 }, [api]);
308
309 const phase = derivePhase(session);
310
311 const onConnect = async (event: FormEvent<HTMLFormElement>) => {
312 event.preventDefault();
313 if (!api || provider !== "codex" || isConnecting) {
314 return;
315 }
316
317 setError(null);
318 setIsConnecting(true);
319 try {
320 const nextSession = await api.providers.startSession({
321 provider,
322 cwd: cwd.trim() || undefined,
323 model: model.trim() || undefined,
324 });
325 currentSessionIdRef.current = nextSession.sessionId;
326 activeAssistantItemRef.current = null;
327 setSession(nextSession);
328 setMessages([]);
329 setEvents([]);
330 } catch (connectError) {
331 setError(
332 connectError instanceof Error
333 ? connectError.message
334 : "Unable to connect to Codex app-server.",
335 );
336 } finally {
337 setIsConnecting(false);
338 }
339 };
340
341 const onDisconnect = async () => {
342 if (!api || !session) {
343 return;
344 }
345
346 await api.providers.stopSession({ sessionId: session.sessionId });
347 currentSessionIdRef.current = null;
348 setSession((previous) =>
349 previous
350 ? {
351 ...previous,
352 status: "closed",
353 activeTurnId: undefined,
354 updatedAt: new Date().toISOString(),
355 }
356 : previous,
357 );
358 };
359
360 const onSendPrompt = async (event: FormEvent<HTMLFormElement>) => {
361 event.preventDefault();
362 if (!api || !session || isSending) {
363 return;
364 }
23 }, [api, dispatch]);
36524
366 const trimmed = prompt.trim();
367 if (!trimmed) {
368 return;
369 }
370
371 setError(null);
372 setMessages((previous) => [
373 ...previous,
374 {
375 id: crypto.randomUUID(),
376 role: "user",
377 text: trimmed,
378 createdAt: new Date().toISOString(),
379 streaming: false,
380 },
381 ]);
382 setPrompt("");
383 setIsSending(true);
384
385 try {
386 await api.providers.sendTurn({
387 sessionId: session.sessionId,
388 input: trimmed,
389 });
390 } catch (sendError) {
391 setError(
392 sendError instanceof Error
393 ? sendError.message
394 : "Failed to start a Codex turn.",
395 );
396 } finally {
397 setIsSending(false);
398 }
399 };
400
401 const onInterrupt = async () => {
402 if (!api || !session) {
403 return;
404 }
25 return null;
26}
40527
406 await api.providers.interruptTurn({
407 sessionId: session.sessionId,
408 turnId: session.activeTurnId,
409 });
410 };
28function Layout() {
29 const api = useMemo(() => readNativeApi(), []);
30 const { state } = useStore();
41131
41232 if (!api) {
41333 return (
414 <main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-10">
415 <section className="surface-card w-full rounded-3xl p-8">
416 <p className="label-chip">CodeThing</p>
417 <h1 className="mt-3 text-3xl">Native bridge unavailable</h1>
418 <p className="mt-3 text-sm text-amber-100/80">
419 Launch the renderer through Electron so `window.nativeApi` is
420 exposed by preload.
34 <div className="flex h-screen items-center justify-center bg-[#0c0c0c] text-[#e0e0e0]">
35 <div className="text-center">
36 <p className="text-sm text-[#a0a0a0]/60">
37 Native bridge unavailable. Launch through Electron.
42138 </p>
422 </section>
423 </main>
39 </div>
40 </div>
42441 );
42542 }
42643
42744 return (
428 <main className="mx-auto min-h-screen w-full max-w-7xl px-4 py-6 text-[#f3f0e9] sm:px-8">
429 <header className="surface-card relative overflow-hidden rounded-3xl px-6 py-7 sm:px-8">
430 <div className="grain absolute inset-0 pointer-events-none" />
431 <p className="label-chip relative">CodeThing / Provider Shell</p>
432 <h1 className="relative mt-3 text-4xl tracking-tight sm:text-5xl">
433 Codex-first workspace
434 </h1>
435 <p className="relative mt-3 max-w-3xl text-sm text-[#efe8d7]/82 sm:text-base">
436 Built on the Codex app-server protocol with a provider abstraction in
437 the IPC layer, so Claude Code can be added without reshaping the UI
438 contract.
439 </p>
440
441 <form
442 className="relative mt-6 grid gap-3 rounded-2xl border border-[#f6a40f]/30 bg-[#0f2034]/65 p-4 sm:grid-cols-6"
443 onSubmit={onConnect}
444 >
445 <label className="sm:col-span-2">
446 <span className="field-label">Provider</span>
447 <select
448 className="field-input"
449 value={provider}
450 onChange={(event) =>
451 setProvider(event.target.value as ProviderKind)
452 }
453 disabled={isConnecting || phase !== "disconnected"}
454 >
455 {PROVIDER_OPTIONS.map((option) => (
456 <option
457 key={option.value}
458 value={option.value}
459 disabled={!option.available}
460 >
461 {option.label}
462 </option>
463 ))}
464 </select>
465 </label>
466
467 <label className="sm:col-span-2">
468 <span className="field-label">Working directory</span>
469 <input
470 className="field-input font-mono text-xs sm:text-sm"
471 value={cwd}
472 onChange={(event) => setCwd(event.target.value)}
473 placeholder="/Users/theo/Code/Work/..."
474 disabled={isConnecting || phase !== "disconnected"}
475 />
476 </label>
477
478 <label className="sm:col-span-1">
479 <span className="field-label">Model</span>
480 <input
481 className="field-input font-mono text-xs sm:text-sm"
482 value={model}
483 onChange={(event) => setModel(event.target.value)}
484 placeholder="gpt-5.1-codex"
485 disabled={isConnecting || phase !== "disconnected"}
486 />
487 </label>
488
489 <div className="sm:col-span-1 flex items-end">
490 {phase === "disconnected" ? (
491 <button
492 type="submit"
493 className="action-button action-primary w-full"
494 disabled={provider !== "codex" || isConnecting}
495 >
496 {isConnecting ? "Connecting..." : "Connect"}
497 </button>
498 ) : (
499 <button
500 type="button"
501 className="action-button action-danger w-full"
502 onClick={() => void onDisconnect()}
503 >
504 Disconnect
505 </button>
506 )}
507 </div>
508 </form>
509
510 <div className="relative mt-4 flex flex-wrap gap-2 text-xs">
511 <span className="status-pill">
512 phase={phase} provider={session?.provider ?? "none"}
513 </span>
514 <span className="status-pill">
515 thread={session?.threadId ?? "none"}
516 </span>
517 <span className="status-pill">
518 turn={session?.activeTurnId ?? "none"}
519 </span>
520 </div>
521 </header>
522
523 {error ? (
524 <div className="mt-4 rounded-2xl border border-rose-300/35 bg-rose-900/35 px-4 py-3 text-sm text-rose-100">
525 {error}
526 </div>
527 ) : null}
528
529 <section className="mt-5 grid gap-4 lg:grid-cols-[2fr_1fr]">
530 <article className="surface-card rounded-3xl p-4 sm:p-5">
531 <h2 className="text-xl">Conversation</h2>
532 <div className="mt-4 max-h-[52vh] space-y-3 overflow-y-auto pr-1">
533 {messages.length === 0 ? (
534 <div className="rounded-2xl border border-dashed border-[#f6d79e]/30 bg-[#0d1827]/70 px-4 py-5 text-sm text-[#d8d0c3]/75">
535 No turns yet. Start a session, then send a prompt.
536 </div>
537 ) : null}
538
539 {messages.map((entry) => (
540 <div
541 key={entry.id}
542 className={`message-card ${
543 entry.role === "user" ? "message-user" : "message-assistant"
544 }`}
545 >
546 <header className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.12em] text-[#d8d0c3]/68">
547 <span>{entry.role === "user" ? "you" : "codex"}</span>
548 <span>{formatTimestamp(entry.createdAt)}</span>
549 </header>
550 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-6">
551 {entry.text || (entry.streaming ? "..." : "(empty response)")}
552 </pre>
553 </div>
554 ))}
555 </div>
556
557 <form className="mt-4 grid gap-3" onSubmit={onSendPrompt}>
558 <textarea
559 className="field-input min-h-28 resize-y font-mono text-sm leading-6"
560 value={prompt}
561 onChange={(event) => setPrompt(event.target.value)}
562 placeholder="Ask Codex to inspect, plan, or edit this project..."
563 disabled={phase === "disconnected" || isSending}
564 />
565 <div className="flex gap-2">
566 <button
567 type="submit"
568 className="action-button action-primary"
569 disabled={phase === "disconnected" || isSending}
570 >
571 {isSending ? "Starting turn..." : "Send"}
572 </button>
573 <button
574 type="button"
575 className="action-button action-muted"
576 disabled={phase !== "running"}
577 onClick={() => void onInterrupt()}
578 >
579 Interrupt
580 </button>
581 </div>
582 </form>
583 </article>
45 <div className="flex h-screen overflow-hidden bg-[#0c0c0c] text-[#e0e0e0]">
46 <EventRouter />
47 <Sidebar />
48 <ChatView />
49 {state.diffOpen && <DiffPanel />}
50 </div>
51 );
52}
58453
585 <aside className="surface-card rounded-3xl p-4 sm:p-5">
586 <h2 className="text-xl">Protocol stream</h2>
587 <p className="mt-2 text-xs text-[#d8d0c3]/76">
588 Live notifications from the app-server (latest first).
589 </p>
590 <div className="mt-4 max-h-[58vh] space-y-2 overflow-y-auto pr-1">
591 {events.length === 0 ? (
592 <p className="rounded-xl border border-dashed border-[#f6d79e]/30 bg-[#0d1827]/70 px-3 py-4 text-xs text-[#d8d0c3]/76">
593 Waiting for events...
594 </p>
595 ) : null}
596 {events.map((entry) => (
597 <div key={entry.id} className="event-card">
598 <p className="font-mono text-[11px] uppercase tracking-[0.12em] text-[#f8ca64]">
599 {entry.method}
600 </p>
601 {entry.message ? (
602 <p className="mt-1 text-xs text-[#efe8d7]/88">
603 {entry.message}
604 </p>
605 ) : null}
606 <p className="mt-2 text-[11px] text-[#d8d0c3]/66">
607 {formatTimestamp(entry.createdAt)}
608 {entry.turnId ? ` · ${entry.turnId}` : ""}
609 </p>
610 </div>
611 ))}
612 </div>
613 </aside>
614 </section>
615 </main>
54export default function App() {
55 return (
56 <StoreProvider>
57 <Layout />
58 </StoreProvider>
61659 );
61760}
apps/renderer/src/components/ChatView.tsx created+302
......@@ -0,0 +1,302 @@
1import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
2
3import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic";
4import { useStore } from "../store";
5
6export default function ChatView() {
7 const { state, dispatch } = useStore();
8 const api = useMemo(() => readNativeApi(), []);
9 const [prompt, setPrompt] = useState("");
10 const [isSending, setIsSending] = useState(false);
11 const [isConnecting, setIsConnecting] = useState(false);
12 const messagesEndRef = useRef<HTMLDivElement>(null);
13 const textareaRef = useRef<HTMLTextAreaElement>(null);
14
15 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
16 const activeProject = state.projects.find(
17 (p) => p.id === activeThread?.projectId,
18 );
19 const phase = derivePhase(activeThread?.session ?? null);
20
21 // Auto-scroll on new messages
22 const messageCount = activeThread?.messages.length ?? 0;
23 // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change
24 useEffect(() => {
25 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
26 }, [messageCount]);
27
28 // Auto-resize textarea
29 // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on prompt change
30 useEffect(() => {
31 const ta = textareaRef.current;
32 if (!ta) return;
33 ta.style.height = "auto";
34 ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
35 }, [prompt]);
36
37 const ensureSession = async (): Promise<boolean> => {
38 if (!api || !activeThread || !activeProject) return false;
39 if (activeThread.session && activeThread.session.status !== "closed")
40 return true;
41
42 setIsConnecting(true);
43 try {
44 const session = await api.providers.startSession({
45 provider: "codex",
46 cwd: activeProject.cwd || undefined,
47 model: activeProject.model || undefined,
48 });
49 dispatch({
50 type: "UPDATE_SESSION",
51 threadId: activeThread.id,
52 session,
53 });
54 return true;
55 } catch (err) {
56 dispatch({
57 type: "SET_ERROR",
58 threadId: activeThread.id,
59 error: err instanceof Error ? err.message : "Failed to connect.",
60 });
61 return false;
62 } finally {
63 setIsConnecting(false);
64 }
65 };
66
67 const onSend = async (e: FormEvent) => {
68 e.preventDefault();
69 if (!api || !activeThread || isSending || isConnecting) return;
70 const trimmed = prompt.trim();
71 if (!trimmed) return;
72
73 // Auto-title from first message
74 if (activeThread.messages.length === 0) {
75 const title =
76 trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed;
77 dispatch({
78 type: "SET_THREAD_TITLE",
79 threadId: activeThread.id,
80 title,
81 });
82 }
83
84 dispatch({
85 type: "SET_ERROR",
86 threadId: activeThread.id,
87 error: null,
88 });
89 dispatch({
90 type: "PUSH_USER_MESSAGE",
91 threadId: activeThread.id,
92 id: crypto.randomUUID(),
93 text: trimmed,
94 });
95 setPrompt("");
96
97 const connected = await ensureSession();
98 if (!connected) return;
99
100 // Re-read thread to get session after potential connection
101 const updatedThread = state.threads.find((t) => t.id === activeThread.id);
102 const sessionId =
103 updatedThread?.session?.sessionId ?? activeThread.session?.sessionId;
104 if (!sessionId) return;
105
106 setIsSending(true);
107 try {
108 await api.providers.sendTurn({ sessionId, input: trimmed });
109 } catch (err) {
110 dispatch({
111 type: "SET_ERROR",
112 threadId: activeThread.id,
113 error: err instanceof Error ? err.message : "Failed to send message.",
114 });
115 } finally {
116 setIsSending(false);
117 }
118 };
119
120 const onInterrupt = async () => {
121 if (!api || !activeThread?.session) return;
122 await api.providers.interruptTurn({
123 sessionId: activeThread.session.sessionId,
124 turnId: activeThread.session.activeTurnId,
125 });
126 };
127
128 const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
129 if (e.key === "Enter" && !e.shiftKey) {
130 e.preventDefault();
131 void onSend(e as unknown as FormEvent);
132 }
133 };
134
135 // Empty state: no active thread
136 if (!activeThread) {
137 return (
138 <div className="flex flex-1 flex-col items-center justify-center bg-[#0c0c0c] text-[#a0a0a0]/40">
139 <div className="text-center">
140 <div className="mb-3 text-4xl">&#x2728;</div>
141 <p className="text-sm">
142 Select a thread or create a new one to get started.
143 </p>
144 </div>
145 </div>
146 );
147 }
148
149 return (
150 <div className="flex flex-1 flex-col bg-[#0c0c0c]">
151 {/* Top bar */}
152 <header className="flex items-center justify-between border-b border-white/[0.08] px-5 py-3">
153 <div className="flex items-center gap-3">
154 <h2 className="text-sm font-medium text-[#e0e0e0]">
155 {activeThread.title}
156 </h2>
157 {activeProject && (
158 <span className="rounded-full bg-white/[0.06] px-2 py-0.5 text-[10px] text-[#a0a0a0]/50">
159 {activeProject.name}
160 </span>
161 )}
162 </div>
163 <div className="flex items-center gap-3">
164 {/* Status indicator */}
165 <div className="flex items-center gap-1.5">
166 <div
167 className={`h-2 w-2 rounded-full ${
168 phase === "running"
169 ? "animate-pulse bg-emerald-400"
170 : phase === "ready"
171 ? "bg-emerald-400"
172 : phase === "connecting"
173 ? "animate-pulse bg-amber-400"
174 : "bg-[#a0a0a0]/30"
175 }`}
176 />
177 <span className="text-[10px] text-[#a0a0a0]/50">{phase}</span>
178 </div>
179 {/* Diff toggle */}
180 <button
181 type="button"
182 className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${
183 state.diffOpen
184 ? "bg-white/10 text-white"
185 : "text-[#a0a0a0]/40 hover:text-[#a0a0a0]/60"
186 }`}
187 onClick={() => dispatch({ type: "TOGGLE_DIFF" })}
188 >
189 Diff
190 </button>
191 </div>
192 </header>
193
194 {/* Error banner */}
195 {activeThread.error && (
196 <div className="mx-4 mt-3 rounded-lg border border-rose-400/20 bg-rose-900/20 px-3 py-2 text-xs text-rose-200">
197 {activeThread.error}
198 </div>
199 )}
200
201 {/* Messages */}
202 <div className="flex-1 overflow-y-auto px-5 py-4">
203 {activeThread.messages.length === 0 ? (
204 <div className="flex h-full items-center justify-center">
205 <p className="text-sm text-[#a0a0a0]/30">
206 Send a message to start the conversation.
207 </p>
208 </div>
209 ) : (
210 <div className="mx-auto max-w-3xl space-y-4">
211 {activeThread.messages.map((msg) => (
212 <div key={msg.id}>
213 {msg.role === "user" ? (
214 <div className="flex justify-end">
215 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-white/[0.08] bg-white/[0.05] px-4 py-3">
216 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-[#e0e0e0]">
217 {msg.text}
218 </pre>
219 <p className="mt-1.5 text-right text-[10px] text-[#a0a0a0]/30">
220 {formatTimestamp(msg.createdAt)}
221 </p>
222 </div>
223 </div>
224 ) : (
225 <div className="border-l-2 border-white/[0.15] pl-4">
226 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-[#d0d0d0]">
227 {msg.text || (msg.streaming ? "" : "(empty response)")}
228 </pre>
229 {msg.streaming && (
230 <span className="inline-flex gap-1 pt-1">
231 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50" />
232 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50 [animation-delay:150ms]" />
233 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50 [animation-delay:300ms]" />
234 </span>
235 )}
236 {!msg.streaming && (
237 <p className="mt-1.5 text-[10px] text-[#a0a0a0]/30">
238 {formatTimestamp(msg.createdAt)}
239 </p>
240 )}
241 </div>
242 )}
243 </div>
244 ))}
245 <div ref={messagesEndRef} />
246 </div>
247 )}
248 </div>
249
250 {/* Input bar */}
251 <div className="border-t border-white/[0.08] px-5 py-3">
252 <form
253 onSubmit={onSend}
254 className="mx-auto flex max-w-3xl items-end gap-2"
255 >
256 <div className="relative flex-1">
257 <textarea
258 ref={textareaRef}
259 className="w-full resize-none rounded-xl border border-white/[0.1] bg-white/[0.04] px-4 py-3 pr-12 font-mono text-sm text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/20 focus:outline-none"
260 rows={1}
261 value={prompt}
262 onChange={(e) => setPrompt(e.target.value)}
263 onKeyDown={onKeyDown}
264 placeholder={
265 phase === "disconnected"
266 ? "Type a message (session auto-connects)..."
267 : "Type a message..."
268 }
269 disabled={isSending || isConnecting}
270 />
271 {activeProject && (
272 <span className="absolute right-3 bottom-2 text-[9px] text-[#a0a0a0]/25">
273 {activeProject.model}
274 </span>
275 )}
276 </div>
277 {phase === "running" ? (
278 <button
279 type="button"
280 className="shrink-0 rounded-xl bg-rose-600/80 px-4 py-3 text-xs font-medium text-white transition-colors duration-150 hover:bg-rose-600"
281 onClick={() => void onInterrupt()}
282 >
283 Stop
284 </button>
285 ) : (
286 <button
287 type="submit"
288 className="shrink-0 rounded-xl bg-white px-4 py-3 text-xs font-medium text-[#0c0c0c] transition-colors duration-150 hover:bg-white/90 disabled:opacity-40"
289 disabled={isSending || isConnecting || !prompt.trim()}
290 >
291 {isConnecting
292 ? "Connecting..."
293 : isSending
294 ? "Sending..."
295 : "Send"}
296 </button>
297 )}
298 </form>
299 </div>
300 </div>
301 );
302}
apps/renderer/src/components/DiffPanel.tsx created+60
......@@ -0,0 +1,60 @@
1import { useStore } from "../store";
2
3export default function DiffPanel() {
4 const { dispatch } = useStore();
5
6 return (
7 <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-white/[0.08] bg-[#141414]">
8 {/* Header */}
9 <div className="flex items-center justify-between border-b border-white/[0.08] px-4 py-3">
10 <h3 className="text-xs font-medium text-[#e0e0e0]">
11 Uncommitted changes
12 </h3>
13 <button
14 type="button"
15 className="text-[#a0a0a0]/40 transition-colors duration-150 hover:text-[#a0a0a0]/70"
16 onClick={() => dispatch({ type: "TOGGLE_DIFF" })}
17 >
18 <span className="text-sm">&times;</span>
19 </button>
20 </div>
21
22 {/* Placeholder content */}
23 <div className="flex-1 overflow-y-auto p-4">
24 <div className="space-y-3">
25 <div className="rounded-lg border border-white/[0.06] bg-white/[0.02] p-3">
26 <div className="mb-2 flex items-center gap-2">
27 <span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400">
28 M
29 </span>
30 <span className="font-mono text-xs text-[#a0a0a0]/60">
31 src/App.tsx
32 </span>
33 </div>
34 <div className="space-y-0.5 font-mono text-[11px]">
35 <div className="text-red-400/60">- const oldValue = "hello";</div>
36 <div className="text-emerald-400/60">
37 + const newValue = "world";
38 </div>
39 </div>
40 </div>
41
42 <div className="rounded-lg border border-white/[0.06] bg-white/[0.02] p-3">
43 <div className="flex items-center gap-2">
44 <span className="rounded bg-blue-500/20 px-1.5 py-0.5 text-[9px] font-medium text-blue-400">
45 A
46 </span>
47 <span className="font-mono text-xs text-[#a0a0a0]/60">
48 src/utils.ts
49 </span>
50 </div>
51 </div>
52 </div>
53
54 <p className="mt-6 text-center text-[10px] text-[#a0a0a0]/25">
55 Diff integration coming soon
56 </p>
57 </div>
58 </aside>
59 );
60}
apps/renderer/src/components/Sidebar.tsx created+222
......@@ -0,0 +1,222 @@
1import { useState } from "react";
2import { useStore } from "../store";
3import type { Project } from "../types";
4
5function formatRelativeTime(iso: string): string {
6 const diff = Date.now() - new Date(iso).getTime();
7 const minutes = Math.floor(diff / 60_000);
8 if (minutes < 1) return "just now";
9 if (minutes < 60) return `${minutes}m ago`;
10 const hours = Math.floor(minutes / 60);
11 if (hours < 24) return `${hours}h ago`;
12 return `${Math.floor(hours / 24)}d ago`;
13}
14
15export default function Sidebar() {
16 const { state, dispatch } = useStore();
17 const [addingProject, setAddingProject] = useState(false);
18 const [newCwd, setNewCwd] = useState("");
19 const [newModel, setNewModel] = useState("gpt-5.1-codex");
20
21 const handleAddProject = () => {
22 const cwd = newCwd.trim();
23 if (!cwd) return;
24 const name = cwd.split("/").filter(Boolean).pop() ?? "project";
25 const project: Project = {
26 id: crypto.randomUUID(),
27 name,
28 cwd,
29 model: newModel.trim() || "gpt-5.1-codex",
30 expanded: true,
31 };
32 dispatch({ type: "ADD_PROJECT", project });
33 setNewCwd("");
34 setNewModel("gpt-5.1-codex");
35 setAddingProject(false);
36 };
37
38 const handleNewThread = (projectId: string) => {
39 dispatch({
40 type: "ADD_THREAD",
41 thread: {
42 id: crypto.randomUUID(),
43 projectId,
44 title: "New thread",
45 session: null,
46 messages: [],
47 events: [],
48 error: null,
49 createdAt: new Date().toISOString(),
50 },
51 });
52 };
53
54 return (
55 <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-white/[0.08] bg-[#141414]">
56 {/* Branding */}
57 <div className="flex items-center gap-2 px-4 pt-5 pb-4">
58 <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-white text-xs font-bold text-[#0c0c0c]">
59 CT
60 </div>
61 <span className="text-sm font-semibold tracking-tight text-[#e0e0e0]">
62 CodeThing
63 </span>
64 </div>
65
66 {/* New thread (global) */}
67 <div className="px-3 pb-3">
68 <button
69 type="button"
70 className="flex w-full items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2 text-xs text-[#a0a0a0]/80 transition-colors duration-150 hover:bg-white/[0.07]"
71 onClick={() => {
72 if (state.projects.length === 0) {
73 setAddingProject(true);
74 return;
75 }
76 const firstProject = state.projects[0];
77 if (firstProject) handleNewThread(firstProject.id);
78 }}
79 >
80 <span className="text-white">+</span>
81 New thread
82 </button>
83 </div>
84
85 {/* Project list */}
86 <nav className="flex-1 overflow-y-auto px-2">
87 {state.projects.map((project) => {
88 const threads = state.threads.filter(
89 (t) => t.projectId === project.id,
90 );
91 return (
92 <div key={project.id} className="mb-1">
93 {/* Project header */}
94 <button
95 type="button"
96 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-white/[0.05]"
97 onClick={() =>
98 dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })
99 }
100 >
101 <span className="text-[10px] text-[#a0a0a0]/50">
102 {project.expanded ? "â–¼" : "â–¶"}
103 </span>
104 <span className="flex-1 truncate text-xs font-medium text-[#e0e0e0]/90">
105 {project.name}
106 </span>
107 <span className="text-[10px] text-[#a0a0a0]/40">
108 {threads.length}
109 </span>
110 </button>
111
112 {/* Threads */}
113 {project.expanded && (
114 <div className="ml-2 border-l border-white/[0.06] pl-2">
115 {threads.map((thread) => {
116 const isActive = state.activeThreadId === thread.id;
117 return (
118 <button
119 key={thread.id}
120 type="button"
121 className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 ${
122 isActive
123 ? "bg-white/10 text-white"
124 : "text-[#a0a0a0]/70 hover:bg-white/[0.04]"
125 }`}
126 onClick={() =>
127 dispatch({
128 type: "SET_ACTIVE_THREAD",
129 threadId: thread.id,
130 })
131 }
132 >
133 <span className="flex-1 truncate text-xs">
134 {thread.title}
135 </span>
136 <span className="shrink-0 text-[10px] text-[#a0a0a0]/30">
137 {formatRelativeTime(thread.createdAt)}
138 </span>
139 </button>
140 );
141 })}
142
143 {/* New thread within project */}
144 <button
145 type="button"
146 className="flex w-full items-center gap-1 px-2 py-1 text-[10px] text-[#a0a0a0]/40 transition-colors duration-150 hover:text-[#a0a0a0]/60"
147 onClick={() => handleNewThread(project.id)}
148 >
149 <span>+</span> New thread
150 </button>
151 </div>
152 )}
153 </div>
154 );
155 })}
156
157 {state.projects.length === 0 && !addingProject && (
158 <div className="px-2 pt-4 text-center text-xs text-[#a0a0a0]/40">
159 No projects yet.
160 <br />
161 Add one to get started.
162 </div>
163 )}
164 </nav>
165
166 {/* Add project form */}
167 {addingProject ? (
168 <div className="border-t border-white/[0.08] p-3">
169 <p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-[#a0a0a0]/50">
170 Add project
171 </p>
172 <input
173 className="mb-2 w-full rounded-md border border-white/[0.1] bg-white/[0.04] px-2 py-1.5 font-mono text-xs text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/30 focus:outline-none"
174 placeholder="/path/to/project"
175 value={newCwd}
176 onChange={(e) => setNewCwd(e.target.value)}
177 onKeyDown={(e) => {
178 if (e.key === "Enter") handleAddProject();
179 if (e.key === "Escape") setAddingProject(false);
180 }}
181 />
182 <input
183 className="mb-2 w-full rounded-md border border-white/[0.1] bg-white/[0.04] px-2 py-1.5 font-mono text-xs text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/30 focus:outline-none"
184 placeholder="model (default: gpt-5.1-codex)"
185 value={newModel}
186 onChange={(e) => setNewModel(e.target.value)}
187 onKeyDown={(e) => {
188 if (e.key === "Enter") handleAddProject();
189 if (e.key === "Escape") setAddingProject(false);
190 }}
191 />
192 <div className="flex gap-2">
193 <button
194 type="button"
195 className="flex-1 rounded-md bg-white px-2 py-1 text-xs font-medium text-[#0c0c0c] transition-colors duration-150 hover:bg-white/90"
196 onClick={handleAddProject}
197 >
198 Add
199 </button>
200 <button
201 type="button"
202 className="flex-1 rounded-md border border-white/[0.1] px-2 py-1 text-xs text-[#a0a0a0]/60 transition-colors duration-150 hover:bg-white/[0.04]"
203 onClick={() => setAddingProject(false)}
204 >
205 Cancel
206 </button>
207 </div>
208 </div>
209 ) : (
210 <div className="border-t border-white/[0.08] p-3">
211 <button
212 type="button"
213 className="flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-white/[0.12] py-2 text-xs text-[#a0a0a0]/50 transition-colors duration-150 hover:border-white/[0.2] hover:text-[#a0a0a0]/70"
214 onClick={() => setAddingProject(true)}
215 >
216 + Add project
217 </button>
218 </div>
219 )}
220 </aside>
221 );
222}
apps/renderer/src/index.css+19-143
......@@ -1,165 +1,41 @@
1@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=IBM+Plex+Mono:wght@400;500;600&display=swap");
21@import "tailwindcss";
32
4@theme {
5 --shadow-soft: 0 14px 38px -18px rgba(15, 23, 42, 0.35);
6}
7
83:root {
94 color-scheme: dark;
105}
116
127body {
13 font-family: "Fraunces", "Iowan Old Style", serif;
8 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
9 "Helvetica Neue", Arial, sans-serif;
10 margin: 0;
11 padding: 0;
1412 min-height: 100vh;
15 color: #f3f0e9;
16 background: radial-gradient(circle at 8% 3%, #e69f15 0%, transparent 33%),
17 radial-gradient(circle at 92% 6%, #2d8ba8 0%, transparent 30%),
18 linear-gradient(160deg, #050913 0%, #091320 42%, #090f1a 100%);
13 color: #e0e0e0;
14 background: #0c0c0c;
1915}
2016
2117pre,
2218code,
2319textarea,
24input,
25select {
26 font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
27}
28
29.surface-card {
30 border: 1px solid rgba(246, 215, 158, 0.24);
31 background: linear-gradient(
32 140deg,
33 rgba(11, 24, 40, 0.9),
34 rgba(9, 16, 29, 0.84)
35 ), radial-gradient(circle at top, rgba(245, 166, 35, 0.15), transparent 45%);
36 box-shadow: 0 28px 70px -45px rgba(0, 0, 0, 0.85), inset 0 1px 0
37 rgba(255, 255, 255, 0.08);
38}
39
40.grain {
41 opacity: 0.18;
42 mix-blend-mode: soft-light;
43 background-image: linear-gradient(
44 45deg,
45 rgba(255, 255, 255, 0.04) 25%,
46 transparent 25%,
47 transparent 50%,
48 rgba(255, 255, 255, 0.04) 50%,
49 rgba(255, 255, 255, 0.04) 75%,
50 transparent 75%,
51 transparent
52 );
53 background-size: 4px 4px;
54}
55
56.label-chip {
57 display: inline-flex;
58 border-radius: 9999px;
59 border: 1px solid rgba(246, 215, 158, 0.44);
60 background: rgba(23, 38, 56, 0.55);
61 padding: 0.28rem 0.72rem;
62 font-size: 0.67rem;
63 letter-spacing: 0.14em;
64 text-transform: uppercase;
65 color: #f8ca64;
66}
67
68.field-label {
69 display: block;
70 margin-bottom: 0.32rem;
71 font-size: 0.67rem;
72 letter-spacing: 0.14em;
73 text-transform: uppercase;
74 color: rgba(246, 233, 201, 0.82);
75}
76
77.field-input {
78 width: 100%;
79 border-radius: 0.8rem;
80 border: 1px solid rgba(246, 215, 158, 0.3);
81 background: rgba(5, 12, 22, 0.85);
82 padding: 0.65rem 0.75rem;
83 color: #f7f1e6;
84 transition: border-color 140ms ease, box-shadow 140ms ease;
85}
86
87.field-input:focus {
88 outline: none;
89 border-color: rgba(250, 206, 99, 0.75);
90 box-shadow: 0 0 0 2px rgba(250, 206, 99, 0.18);
91}
92
93.action-button {
94 border-radius: 0.8rem;
95 padding: 0.62rem 0.9rem;
96 font-size: 0.8rem;
97 font-weight: 600;
98 letter-spacing: 0.08em;
99 text-transform: uppercase;
100 transition: filter 140ms ease, transform 140ms ease;
101}
102
103.action-button:disabled {
104 cursor: not-allowed;
105 opacity: 0.45;
106}
107
108.action-button:not(:disabled):hover {
109 filter: brightness(1.06);
110 transform: translateY(-1px);
111}
112
113.action-primary {
114 border: 1px solid rgba(250, 206, 99, 0.6);
115 background: linear-gradient(140deg, #f8c861, #e59f0d);
116 color: #1f2530;
117}
118
119.action-danger {
120 border: 1px solid rgba(252, 148, 124, 0.44);
121 background: linear-gradient(140deg, #8f2c2b, #bf4738);
122 color: #fff3ef;
123}
124
125.action-muted {
126 border: 1px solid rgba(149, 181, 205, 0.4);
127 background: rgba(13, 31, 49, 0.85);
128 color: #dfecfb;
129}
130
131.status-pill {
132 border-radius: 9999px;
133 border: 1px solid rgba(246, 215, 158, 0.29);
134 background: rgba(9, 20, 33, 0.9);
135 padding: 0.27rem 0.64rem;
136 font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
137 color: rgba(248, 235, 206, 0.86);
20input {
21 font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo,
22 monospace;
13823}
13924
140.message-card {
141 border-radius: 1rem;
142 border: 1px solid rgba(246, 215, 158, 0.25);
143 background: rgba(8, 16, 27, 0.82);
144 padding: 0.9rem;
25/* Scrollbar styling */
26::-webkit-scrollbar {
27 width: 6px;
14528}
14629
147.message-user {
148 border-color: rgba(111, 198, 238, 0.38);
149 background: linear-gradient(
150 160deg,
151 rgba(14, 44, 64, 0.92),
152 rgba(8, 21, 36, 0.92)
153 );
30::-webkit-scrollbar-track {
31 background: transparent;
15432}
15533
156.message-assistant {
157 border-color: rgba(250, 206, 99, 0.34);
34::-webkit-scrollbar-thumb {
35 background: rgba(255, 255, 255, 0.1);
36 border-radius: 3px;
15837}
15938
160.event-card {
161 border-radius: 0.85rem;
162 border: 1px solid rgba(246, 215, 158, 0.2);
163 background: rgba(8, 16, 27, 0.76);
164 padding: 0.62rem 0.72rem;
39::-webkit-scrollbar-thumb:hover {
40 background: rgba(255, 255, 255, 0.18);
16541}
apps/renderer/src/session-logic.ts created+206
......@@ -0,0 +1,206 @@
1import type {
2 ProviderEvent,
3 ProviderKind,
4 ProviderSession,
5} from "@acme/contracts";
6import type { ChatMessage, SessionPhase } from "./types";
7
8export const PROVIDER_OPTIONS: Array<{
9 value: ProviderKind;
10 label: string;
11 available: boolean;
12}> = [
13 { value: "codex", label: "Codex", available: true },
14 { value: "claudeCode", label: "Claude Code (soon)", available: false },
15];
16
17export function readNativeApi() {
18 if (typeof window === "undefined") return undefined;
19 return window.nativeApi;
20}
21
22export function asObject(value: unknown): Record<string, unknown> | undefined {
23 if (!value || typeof value !== "object") return undefined;
24 return value as Record<string, unknown>;
25}
26
27export function asString(value: unknown): string | undefined {
28 return typeof value === "string" ? value : undefined;
29}
30
31export function formatTimestamp(isoDate: string): string {
32 return new Intl.DateTimeFormat(undefined, {
33 hour: "numeric",
34 minute: "2-digit",
35 second: "2-digit",
36 }).format(new Date(isoDate));
37}
38
39export function derivePhase(session: ProviderSession | null): SessionPhase {
40 if (!session || session.status === "closed") return "disconnected";
41 if (session.status === "connecting") return "connecting";
42 if (session.status === "running") return "running";
43 return "ready";
44}
45
46export function evolveSession(
47 previous: ProviderSession,
48 event: ProviderEvent,
49): ProviderSession {
50 const payload = asObject(event.payload);
51
52 if (event.method === "thread/started") {
53 const thread = asObject(payload?.thread);
54 return {
55 ...previous,
56 threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId,
57 updatedAt: event.createdAt,
58 };
59 }
60
61 if (event.method === "turn/started") {
62 const turn = asObject(payload?.turn);
63 return {
64 ...previous,
65 status: "running",
66 activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId,
67 updatedAt: event.createdAt,
68 };
69 }
70
71 if (event.method === "turn/completed") {
72 const turn = asObject(payload?.turn);
73 const status = asString(turn?.status);
74 const turnError = asObject(turn?.error);
75 return {
76 ...previous,
77 status: status === "failed" ? "error" : "ready",
78 activeTurnId: undefined,
79 lastError: asString(turnError?.message) ?? previous.lastError,
80 updatedAt: event.createdAt,
81 };
82 }
83
84 if (event.kind === "error") {
85 return {
86 ...previous,
87 status: "error",
88 lastError: event.message ?? previous.lastError,
89 updatedAt: event.createdAt,
90 };
91 }
92
93 if (event.method === "session/closed" || event.method === "session/exited") {
94 return {
95 ...previous,
96 status: "closed",
97 activeTurnId: undefined,
98 lastError: event.message ?? previous.lastError,
99 updatedAt: event.createdAt,
100 };
101 }
102
103 return { ...previous, updatedAt: event.createdAt };
104}
105
106export function applyEventToMessages(
107 previous: ChatMessage[],
108 event: ProviderEvent,
109 activeAssistantItemRef: { current: string | null },
110): ChatMessage[] {
111 const payload = asObject(event.payload);
112
113 if (event.method === "item/started") {
114 const item = asObject(payload?.item);
115 if (asString(item?.type) !== "agentMessage") return previous;
116 const itemId = asString(item?.id);
117 if (!itemId) return previous;
118
119 activeAssistantItemRef.current = itemId;
120 const seedText = asString(item?.text) ?? "";
121 const filtered = previous.filter((entry) => entry.id !== itemId);
122 return [
123 ...filtered,
124 {
125 id: itemId,
126 role: "assistant",
127 text: seedText,
128 createdAt: event.createdAt,
129 streaming: true,
130 },
131 ];
132 }
133
134 if (event.method === "item/agentMessage/delta") {
135 const itemId = event.itemId ?? asString(payload?.itemId);
136 const delta = event.textDelta ?? asString(payload?.delta) ?? "";
137 if (!itemId || !delta) return previous;
138
139 const existingIndex = previous.findIndex((entry) => entry.id === itemId);
140 if (existingIndex === -1) {
141 activeAssistantItemRef.current = itemId;
142 return [
143 ...previous,
144 {
145 id: itemId,
146 role: "assistant",
147 text: delta,
148 createdAt: event.createdAt,
149 streaming: true,
150 },
151 ];
152 }
153
154 const updated = [...previous];
155 const existing = updated[existingIndex];
156 if (!existing) return previous;
157 updated[existingIndex] = {
158 ...existing,
159 text: `${existing.text}${delta}`,
160 streaming: true,
161 };
162 return updated;
163 }
164
165 if (event.method === "item/completed") {
166 const item = asObject(payload?.item);
167 if (asString(item?.type) !== "agentMessage") return previous;
168 const itemId = asString(item?.id);
169 if (!itemId) return previous;
170
171 const fullText = asString(item?.text);
172 const existingIndex = previous.findIndex((entry) => entry.id === itemId);
173 if (existingIndex === -1) {
174 return [
175 ...previous,
176 {
177 id: itemId,
178 role: "assistant",
179 text: fullText ?? "",
180 createdAt: event.createdAt,
181 streaming: false,
182 },
183 ];
184 }
185
186 const updated = [...previous];
187 const existing = updated[existingIndex];
188 if (!existing) return previous;
189 updated[existingIndex] = {
190 ...existing,
191 text: fullText ?? existing.text,
192 streaming: false,
193 };
194
195 if (activeAssistantItemRef.current === itemId) {
196 activeAssistantItemRef.current = null;
197 }
198 return updated;
199 }
200
201 if (event.method === "turn/completed") {
202 return previous.map((entry) => ({ ...entry, streaming: false }));
203 }
204
205 return previous;
206}
apps/renderer/src/store.ts created+186
......@@ -0,0 +1,186 @@
1import {
2 type Dispatch,
3 type ReactNode,
4 createContext,
5 createElement,
6 useContext,
7 useReducer,
8} from "react";
9
10import type { ProviderEvent, ProviderSession } from "@acme/contracts";
11import { applyEventToMessages, evolveSession } from "./session-logic";
12import type { Project, Thread } from "./types";
13
14// ── Actions ──────────────────────────────────────────────────────────
15
16type Action =
17 | { type: "ADD_PROJECT"; project: Project }
18 | { type: "TOGGLE_PROJECT"; projectId: string }
19 | { type: "ADD_THREAD"; thread: Thread }
20 | { type: "SET_ACTIVE_THREAD"; threadId: string }
21 | { type: "TOGGLE_DIFF" }
22 | {
23 type: "APPLY_EVENT";
24 event: ProviderEvent;
25 activeAssistantItemRef: { current: string | null };
26 }
27 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
28 | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string }
29 | { type: "SET_ERROR"; threadId: string; error: string | null }
30 | { type: "SET_THREAD_TITLE"; threadId: string; title: string };
31
32// ── State ────────────────────────────────────────────────────────────
33
34export interface AppState {
35 projects: Project[];
36 threads: Thread[];
37 activeThreadId: string | null;
38 diffOpen: boolean;
39}
40
41const initialState: AppState = {
42 projects: [],
43 threads: [],
44 activeThreadId: null,
45 diffOpen: false,
46};
47
48// ── Helpers ──────────────────────────────────────────────────────────
49
50function updateThread(
51 threads: Thread[],
52 threadId: string,
53 updater: (t: Thread) => Thread,
54): Thread[] {
55 return threads.map((t) => (t.id === threadId ? updater(t) : t));
56}
57
58function findThreadBySessionId(
59 threads: Thread[],
60 sessionId: string,
61): Thread | undefined {
62 return threads.find((t) => t.session?.sessionId === sessionId);
63}
64
65// ── Reducer ──────────────────────────────────────────────────────────
66
67function reducer(state: AppState, action: Action): AppState {
68 switch (action.type) {
69 case "ADD_PROJECT":
70 return { ...state, projects: [...state.projects, action.project] };
71
72 case "TOGGLE_PROJECT":
73 return {
74 ...state,
75 projects: state.projects.map((p) =>
76 p.id === action.projectId ? { ...p, expanded: !p.expanded } : p,
77 ),
78 };
79
80 case "ADD_THREAD":
81 return {
82 ...state,
83 threads: [...state.threads, action.thread],
84 activeThreadId: action.thread.id,
85 };
86
87 case "SET_ACTIVE_THREAD":
88 return { ...state, activeThreadId: action.threadId };
89
90 case "TOGGLE_DIFF":
91 return { ...state, diffOpen: !state.diffOpen };
92
93 case "APPLY_EVENT": {
94 const { event, activeAssistantItemRef } = action;
95 const target = findThreadBySessionId(state.threads, event.sessionId);
96 if (!target) return state;
97
98 return {
99 ...state,
100 threads: updateThread(state.threads, target.id, (t) => ({
101 ...t,
102 session: t.session ? evolveSession(t.session, event) : t.session,
103 messages: applyEventToMessages(
104 t.messages,
105 event,
106 activeAssistantItemRef,
107 ),
108 events: [event, ...t.events].slice(0, 200),
109 error:
110 event.kind === "error" && event.message ? event.message : t.error,
111 })),
112 };
113 }
114
115 case "UPDATE_SESSION":
116 return {
117 ...state,
118 threads: updateThread(state.threads, action.threadId, (t) => ({
119 ...t,
120 session: action.session,
121 messages: [],
122 events: [],
123 error: null,
124 })),
125 };
126
127 case "PUSH_USER_MESSAGE":
128 return {
129 ...state,
130 threads: updateThread(state.threads, action.threadId, (t) => ({
131 ...t,
132 messages: [
133 ...t.messages,
134 {
135 id: action.id,
136 role: "user" as const,
137 text: action.text,
138 createdAt: new Date().toISOString(),
139 streaming: false,
140 },
141 ],
142 })),
143 };
144
145 case "SET_ERROR":
146 return {
147 ...state,
148 threads: updateThread(state.threads, action.threadId, (t) => ({
149 ...t,
150 error: action.error,
151 })),
152 };
153
154 case "SET_THREAD_TITLE":
155 return {
156 ...state,
157 threads: updateThread(state.threads, action.threadId, (t) => ({
158 ...t,
159 title: action.title,
160 })),
161 };
162
163 default:
164 return state;
165 }
166}
167
168// ── Context ──────────────────────────────────────────────────────────
169
170const StoreContext = createContext<{
171 state: AppState;
172 dispatch: Dispatch<Action>;
173}>({ state: initialState, dispatch: () => {} });
174
175export function StoreProvider({ children }: { children: ReactNode }) {
176 const [state, dispatch] = useReducer(reducer, initialState);
177 return createElement(
178 StoreContext.Provider,
179 { value: { state, dispatch } },
180 children,
181 );
182}
183
184export function useStore() {
185 return useContext(StoreContext);
186}
apps/renderer/src/types.ts created+30
......@@ -0,0 +1,30 @@
1import type { ProviderEvent, ProviderSession } from "@acme/contracts";
2
3export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
4
5export interface ChatMessage {
6 id: string;
7 role: "user" | "assistant";
8 text: string;
9 createdAt: string;
10 streaming: boolean;
11}
12
13export interface Project {
14 id: string;
15 name: string;
16 cwd: string;
17 model: string;
18 expanded: boolean;
19}
20
21export interface Thread {
22 id: string;
23 projectId: string;
24 title: string;
25 session: ProviderSession | null;
26 messages: ChatMessage[];
27 events: ProviderEvent[];
28 error: string | null;
29 createdAt: string;
30}