| 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 | 61 | const fatalPatterns = [ |
| 62 | 62 | "Cannot find module", |
| 63 | 63 | "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 | |
| 66 | 66 | "Uncaught Error", |
| 67 | 67 | "Uncaught TypeError", |
| 68 | 68 | "Uncaught ReferenceError", |
apps/renderer/src/App.tsx+38-595| ... | ... | @@ -1,617 +1,60 @@ |
| 1 | import { | |
| 2 | type FormEvent, | |
| 3 | type MutableRefObject, | |
| 4 | useEffect, | |
| 5 | useMemo, | |
| 6 | useRef, | |
| 7 | useState, | |
| 8 | } from "react"; | |
| 1 | import { useEffect, useMemo, useRef } from "react"; | |
| 9 | 2 | |
| 10 | import type { | |
| 11 | ProviderEvent, | |
| 12 | ProviderKind, | |
| 13 | ProviderSession, | |
| 14 | } from "@acme/contracts"; | |
| 3 | import ChatView from "./components/ChatView"; | |
| 4 | import DiffPanel from "./components/DiffPanel"; | |
| 5 | import Sidebar from "./components/Sidebar"; | |
| 6 | import { readNativeApi } from "./session-logic"; | |
| 7 | import { StoreProvider, useStore } from "./store"; | |
| 15 | 8 | |
| 16 | type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; | |
| 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() { | |
| 9 | function EventRouter() { | |
| 262 | 10 | 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(); | |
| 275 | 12 | const activeAssistantItemRef = useRef<string | null>(null); |
| 276 | 13 | |
| 277 | 14 | useEffect(() => { |
| 278 | currentSessionIdRef.current = session?.sessionId ?? null; | |
| 279 | }, [session?.sessionId]); | |
| 280 | ||
| 281 | useEffect(() => { | |
| 282 | if (!api) { | |
| 283 | return; | |
| 284 | } | |
| 285 | ||
| 15 | if (!api) return; | |
| 286 | 16 | 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, | |
| 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]); | |
| 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]); | |
| 365 | 24 | |
| 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 | } | |
| 405 | 27 | |
| 406 | await api.providers.interruptTurn({ | |
| 407 | sessionId: session.sessionId, | |
| 408 | turnId: session.activeTurnId, | |
| 409 | }); | |
| 410 | }; | |
| 28 | function Layout() { | |
| 29 | const api = useMemo(() => readNativeApi(), []); | |
| 30 | const { state } = useStore(); | |
| 411 | 31 | |
| 412 | 32 | if (!api) { |
| 413 | 33 | 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. | |
| 421 | 38 | </p> |
| 422 | </section> | |
| 423 | </main> | |
| 39 | </div> | |
| 40 | </div> | |
| 424 | 41 | ); |
| 425 | 42 | } |
| 426 | 43 | |
| 427 | 44 | 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 | } | |
| 584 | 53 | |
| 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> | |
| 54 | export default function App() { | |
| 55 | return ( | |
| 56 | <StoreProvider> | |
| 57 | <Layout /> | |
| 58 | </StoreProvider> | |
| 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 | @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 | 1 | @import "tailwindcss"; |
| 3 | 2 | |
| 4 | @theme { | |
| 5 | --shadow-soft: 0 14px 38px -18px rgba(15, 23, 42, 0.35); | |
| 6 | } | |
| 7 | ||
| 8 | 3 | :root { |
| 9 | 4 | color-scheme: dark; |
| 10 | 5 | } |
| 11 | 6 | |
| 12 | 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 | 12 | 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; | |
| 19 | 15 | } |
| 20 | 16 | |
| 21 | 17 | pre, |
| 22 | 18 | code, |
| 23 | 19 | textarea, |
| 24 | input, | |
| 25 | select { | |
| 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); | |
| 20 | input { | |
| 21 | font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, | |
| 22 | monospace; | |
| 138 | 23 | } |
| 139 | 24 | |
| 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; | |
| 145 | 28 | } |
| 146 | 29 | |
| 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; | |
| 154 | 32 | } |
| 155 | 33 | |
| 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; | |
| 158 | 37 | } |
| 159 | 38 | |
| 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); | |
| 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 | } |