| author | |
| committer | |
| log | 9ae4d50a98cdbeaf0f9978a86beb7dcb4d6a1d4d |
| tree | b1cf92edfd2779d6b24f2a0ada41ec70b3eb231a |
| parent | f733cb7e5f44344a1feb4dc82b18739548a9c9a4 |
| signature | Commit is signed but in an unrecognized format. |
9 files changed, 1065 insertions(+), 740 deletions(-)
apps/desktop/scripts/smoke-test.mjs+2-2| ... | @@ -61,8 +61,8 @@ child.on("exit", () => { | ... | @@ -61,8 +61,8 @@ child.on("exit", () => { |
| 61 | const fatalPatterns = [ | 61 | const fatalPatterns = [ |
| 62 | "Cannot find module", | 62 | "Cannot find module", |
| 63 | "MODULE_NOT_FOUND", | 63 | "MODULE_NOT_FOUND", |
| 64 | "Refused to execute", // CSP blocking scripts | 64 | "Refused to execute", // CSP blocking scripts |
| 65 | "can't detect preamble", // @vitejs/plugin-react failure | 65 | "can't detect preamble", // @vitejs/plugin-react failure |
| 66 | "Uncaught Error", | 66 | "Uncaught Error", |
| 67 | "Uncaught TypeError", | 67 | "Uncaught TypeError", |
| 68 | "Uncaught ReferenceError", | 68 | "Uncaught ReferenceError", |
apps/renderer/src/App.tsx+38-595| ... | @@ -1,617 +1,60 @@ | ... | @@ -1,617 +1,60 @@ |
| 1 | import { | 1 | import { useEffect, useMemo, useRef } from "react"; |
| 2 | type FormEvent, | ||
| 3 | type MutableRefObject, | ||
| 4 | useEffect, | ||
| 5 | useMemo, | ||
| 6 | useRef, | ||
| 7 | useState, | ||
| 8 | } from "react"; | ||
| 9 | 2 | ||
| 10 | import type { | 3 | import ChatView from "./components/ChatView"; |
| 11 | ProviderEvent, | 4 | import DiffPanel from "./components/DiffPanel"; |
| 12 | ProviderKind, | 5 | import Sidebar from "./components/Sidebar"; |
| 13 | ProviderSession, | 6 | import { readNativeApi } from "./session-logic"; |
| 14 | } from "@acme/contracts"; | 7 | import { StoreProvider, useStore } from "./store"; |
| 15 | 8 | ||
| 16 | type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; | 9 | function EventRouter() { |
| 17 | |||
| 18 | interface ChatMessage { | ||
| 19 | id: string; | ||
| 20 | role: "user" | "assistant"; | ||
| 21 | text: string; | ||
| 22 | createdAt: string; | ||
| 23 | streaming: boolean; | ||
| 24 | } | ||
| 25 | |||
| 26 | const 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 | |||
| 35 | function readNativeApi() { | ||
| 36 | if (typeof window === "undefined") { | ||
| 37 | return undefined; | ||
| 38 | } | ||
| 39 | |||
| 40 | return window.nativeApi; | ||
| 41 | } | ||
| 42 | |||
| 43 | function 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 | |||
| 51 | function asString(value: unknown): string | undefined { | ||
| 52 | return typeof value === "string" ? value : undefined; | ||
| 53 | } | ||
| 54 | |||
| 55 | function 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 | |||
| 63 | function 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 | |||
| 79 | function 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 | |||
| 142 | function 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 | |||
| 261 | export default function App() { | ||
| 262 | const api = useMemo(() => readNativeApi(), []); | 10 | const api = useMemo(() => readNativeApi(), []); |
| 263 | const [provider, setProvider] = useState<ProviderKind>("codex"); | 11 | const { dispatch } = useStore(); |
| 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); | ||
| 275 | const activeAssistantItemRef = useRef<string | null>(null); | 12 | const activeAssistantItemRef = useRef<string | null>(null); |
| 276 | 13 | ||
| 277 | useEffect(() => { | 14 | useEffect(() => { |
| 278 | currentSessionIdRef.current = session?.sessionId ?? null; | 15 | if (!api) return; |
| 279 | }, [session?.sessionId]); | ||
| 280 | |||
| 281 | useEffect(() => { | ||
| 282 | if (!api) { | ||
| 283 | return; | ||
| 284 | } | ||
| 285 | |||
| 286 | return api.providers.onEvent((event) => { | 16 | return api.providers.onEvent((event) => { |
| 287 | const currentSessionId = currentSessionIdRef.current; | 17 | dispatch({ |
| 288 | if (!currentSessionId || currentSessionId !== event.sessionId) { | 18 | type: "APPLY_EVENT", |
| 289 | return; | 19 | event, |
| 290 | } | 20 | activeAssistantItemRef, |
| 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); | ||
| 298 | }); | 21 | }); |
| 299 | setMessages((previous) => | ||
| 300 | applyEventToMessages(previous, event, activeAssistantItemRef), | ||
| 301 | ); | ||
| 302 | |||
| 303 | if (event.kind === "error" && event.message) { | ||
| 304 | setError(event.message); | ||
| 305 | } | ||
| 306 | }); | 22 | }); |
| 307 | }, [api]); | 23 | }, [api, dispatch]); |
| 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 | } | ||
| 365 | 24 | ||
| 366 | const trimmed = prompt.trim(); | 25 | return null; |
| 367 | if (!trimmed) { | 26 | } |
| 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 | } | ||
| 405 | 27 | ||
| 406 | await api.providers.interruptTurn({ | 28 | function Layout() { |
| 407 | sessionId: session.sessionId, | 29 | const api = useMemo(() => readNativeApi(), []); |
| 408 | turnId: session.activeTurnId, | 30 | const { state } = useStore(); |
| 409 | }); | ||
| 410 | }; | ||
| 411 | 31 | ||
| 412 | if (!api) { | 32 | if (!api) { |
| 413 | return ( | 33 | return ( |
| 414 | <main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-10"> | 34 | <div className="flex h-screen items-center justify-center bg-[#0c0c0c] text-[#e0e0e0]"> |
| 415 | <section className="surface-card w-full rounded-3xl p-8"> | 35 | <div className="text-center"> |
| 416 | <p className="label-chip">CodeThing</p> | 36 | <p className="text-sm text-[#a0a0a0]/60"> |
| 417 | <h1 className="mt-3 text-3xl">Native bridge unavailable</h1> | 37 | Native bridge unavailable. Launch through Electron. |
| 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. | ||
| 421 | </p> | 38 | </p> |
| 422 | </section> | 39 | </div> |
| 423 | </main> | 40 | </div> |
| 424 | ); | 41 | ); |
| 425 | } | 42 | } |
| 426 | 43 | ||
| 427 | return ( | 44 | return ( |
| 428 | <main className="mx-auto min-h-screen w-full max-w-7xl px-4 py-6 text-[#f3f0e9] sm:px-8"> | 45 | <div className="flex h-screen overflow-hidden bg-[#0c0c0c] text-[#e0e0e0]"> |
| 429 | <header className="surface-card relative overflow-hidden rounded-3xl px-6 py-7 sm:px-8"> | 46 | <EventRouter /> |
| 430 | <div className="grain absolute inset-0 pointer-events-none" /> | 47 | <Sidebar /> |
| 431 | <p className="label-chip relative">CodeThing / Provider Shell</p> | 48 | <ChatView /> |
| 432 | <h1 className="relative mt-3 text-4xl tracking-tight sm:text-5xl"> | 49 | {state.diffOpen && <DiffPanel />} |
| 433 | Codex-first workspace | 50 | </div> |
| 434 | </h1> | 51 | ); |
| 435 | <p className="relative mt-3 max-w-3xl text-sm text-[#efe8d7]/82 sm:text-base"> | 52 | } |
| 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> | ||
| 584 | 53 | ||
| 585 | <aside className="surface-card rounded-3xl p-4 sm:p-5"> | 54 | export default function App() { |
| 586 | <h2 className="text-xl">Protocol stream</h2> | 55 | return ( |
| 587 | <p className="mt-2 text-xs text-[#d8d0c3]/76"> | 56 | <StoreProvider> |
| 588 | Live notifications from the app-server (latest first). | 57 | <Layout /> |
| 589 | </p> | 58 | </StoreProvider> |
| 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> | ||
| 616 | ); | 59 | ); |
| 617 | } | 60 | } |
apps/renderer/src/components/ChatView.tsx created+302| ... | @@ -0,0 +1,302 @@ | ||
| 1 | import { type FormEvent, useEffect, useMemo, useRef, useState } from "react"; | ||
| 2 | |||
| 3 | import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic"; | ||
| 4 | import { useStore } from "../store"; | ||
| 5 | |||
| 6 | export 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 @@ | ||
| 1 | import { useStore } from "../store"; | ||
| 2 | |||
| 3 | export 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 @@ | ||
| 1 | import { useState } from "react"; | ||
| 2 | import { useStore } from "../store"; | ||
| 3 | import type { Project } from "../types"; | ||
| 4 | |||
| 5 | function 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 | |||
| 15 | export 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,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"); | ||
| 2 | @import "tailwindcss"; | 1 | @import "tailwindcss"; |
| 3 | 2 | ||
| 4 | @theme { | ||
| 5 | --shadow-soft: 0 14px 38px -18px rgba(15, 23, 42, 0.35); | ||
| 6 | } | ||
| 7 | |||
| 8 | :root { | 3 | :root { |
| 9 | color-scheme: dark; | 4 | color-scheme: dark; |
| 10 | } | 5 | } |
| 11 | 6 | ||
| 12 | body { | 7 | body { |
| 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; | ||
| 14 | min-height: 100vh; | 12 | min-height: 100vh; |
| 15 | color: #f3f0e9; | 13 | color: #e0e0e0; |
| 16 | background: radial-gradient(circle at 8% 3%, #e69f15 0%, transparent 33%), | 14 | background: #0c0c0c; |
| 17 | radial-gradient(circle at 92% 6%, #2d8ba8 0%, transparent 30%), | ||
| 18 | linear-gradient(160deg, #050913 0%, #091320 42%, #090f1a 100%); | ||
| 19 | } | 15 | } |
| 20 | 16 | ||
| 21 | pre, | 17 | pre, |
| 22 | code, | 18 | code, |
| 23 | textarea, | 19 | textarea, |
| 24 | input, | 20 | input { |
| 25 | select { | 21 | font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, |
| 26 | font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; | 22 | 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); | ||
| 138 | } | 23 | } |
| 139 | 24 | ||
| 140 | .message-card { | 25 | /* Scrollbar styling */ |
| 141 | border-radius: 1rem; | 26 | ::-webkit-scrollbar { |
| 142 | border: 1px solid rgba(246, 215, 158, 0.25); | 27 | width: 6px; |
| 143 | background: rgba(8, 16, 27, 0.82); | ||
| 144 | padding: 0.9rem; | ||
| 145 | } | 28 | } |
| 146 | 29 | ||
| 147 | .message-user { | 30 | ::-webkit-scrollbar-track { |
| 148 | border-color: rgba(111, 198, 238, 0.38); | 31 | background: transparent; |
| 149 | background: linear-gradient( | ||
| 150 | 160deg, | ||
| 151 | rgba(14, 44, 64, 0.92), | ||
| 152 | rgba(8, 21, 36, 0.92) | ||
| 153 | ); | ||
| 154 | } | 32 | } |
| 155 | 33 | ||
| 156 | .message-assistant { | 34 | ::-webkit-scrollbar-thumb { |
| 157 | border-color: rgba(250, 206, 99, 0.34); | 35 | background: rgba(255, 255, 255, 0.1); |
| 36 | border-radius: 3px; | ||
| 158 | } | 37 | } |
| 159 | 38 | ||
| 160 | .event-card { | 39 | ::-webkit-scrollbar-thumb:hover { |
| 161 | border-radius: 0.85rem; | 40 | background: rgba(255, 255, 255, 0.18); |
| 162 | border: 1px solid rgba(246, 215, 158, 0.2); | ||
| 163 | background: rgba(8, 16, 27, 0.76); | ||
| 164 | padding: 0.62rem 0.72rem; | ||
| 165 | } | 41 | } |
apps/renderer/src/session-logic.ts created+206| ... | @@ -0,0 +1,206 @@ | ||
| 1 | import type { | ||
| 2 | ProviderEvent, | ||
| 3 | ProviderKind, | ||
| 4 | ProviderSession, | ||
| 5 | } from "@acme/contracts"; | ||
| 6 | import type { ChatMessage, SessionPhase } from "./types"; | ||
| 7 | |||
| 8 | export 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 | |||
| 17 | export function readNativeApi() { | ||
| 18 | if (typeof window === "undefined") return undefined; | ||
| 19 | return window.nativeApi; | ||
| 20 | } | ||
| 21 | |||
| 22 | export 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 | |||
| 27 | export function asString(value: unknown): string | undefined { | ||
| 28 | return typeof value === "string" ? value : undefined; | ||
| 29 | } | ||
| 30 | |||
| 31 | export 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 | |||
| 39 | export 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 | |||
| 46 | export 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 | |||
| 106 | export 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 @@ | ||
| 1 | import { | ||
| 2 | type Dispatch, | ||
| 3 | type ReactNode, | ||
| 4 | createContext, | ||
| 5 | createElement, | ||
| 6 | useContext, | ||
| 7 | useReducer, | ||
| 8 | } from "react"; | ||
| 9 | |||
| 10 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; | ||
| 11 | import { applyEventToMessages, evolveSession } from "./session-logic"; | ||
| 12 | import type { Project, Thread } from "./types"; | ||
| 13 | |||
| 14 | // ── Actions ────────────────────────────────────────────────────────── | ||
| 15 | |||
| 16 | type 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 | |||
| 34 | export interface AppState { | ||
| 35 | projects: Project[]; | ||
| 36 | threads: Thread[]; | ||
| 37 | activeThreadId: string | null; | ||
| 38 | diffOpen: boolean; | ||
| 39 | } | ||
| 40 | |||
| 41 | const initialState: AppState = { | ||
| 42 | projects: [], | ||
| 43 | threads: [], | ||
| 44 | activeThreadId: null, | ||
| 45 | diffOpen: false, | ||
| 46 | }; | ||
| 47 | |||
| 48 | // ── Helpers ────────────────────────────────────────────────────────── | ||
| 49 | |||
| 50 | function 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 | |||
| 58 | function findThreadBySessionId( | ||
| 59 | threads: Thread[], | ||
| 60 | sessionId: string, | ||
| 61 | ): Thread | undefined { | ||
| 62 | return threads.find((t) => t.session?.sessionId === sessionId); | ||
| 63 | } | ||
| 64 | |||
| 65 | // ── Reducer ────────────────────────────────────────────────────────── | ||
| 66 | |||
| 67 | function 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 | |||
| 170 | const StoreContext = createContext<{ | ||
| 171 | state: AppState; | ||
| 172 | dispatch: Dispatch<Action>; | ||
| 173 | }>({ state: initialState, dispatch: () => {} }); | ||
| 174 | |||
| 175 | export 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 | |||
| 184 | export function useStore() { | ||
| 185 | return useContext(StoreContext); | ||
| 186 | } | ||
apps/renderer/src/types.ts created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; | ||
| 2 | |||
| 3 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; | ||
| 4 | |||
| 5 | export interface ChatMessage { | ||
| 6 | id: string; | ||
| 7 | role: "user" | "assistant"; | ||
| 8 | text: string; | ||
| 9 | createdAt: string; | ||
| 10 | streaming: boolean; | ||
| 11 | } | ||
| 12 | |||
| 13 | export interface Project { | ||
| 14 | id: string; | ||
| 15 | name: string; | ||
| 16 | cwd: string; | ||
| 17 | model: string; | ||
| 18 | expanded: boolean; | ||
| 19 | } | ||
| 20 | |||
| 21 | export 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 | } | ||