diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index b0bfde7afa161cbfd4e664b8e29ae1eececaf044..62884bcde3eed6134d9edfdb845671a2028b5a86 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -61,8 +61,8 @@ child.on("exit", () => { const fatalPatterns = [ "Cannot find module", "MODULE_NOT_FOUND", - "Refused to execute", // CSP blocking scripts - "can't detect preamble", // @vitejs/plugin-react failure + "Refused to execute", // CSP blocking scripts + "can't detect preamble", // @vitejs/plugin-react failure "Uncaught Error", "Uncaught TypeError", "Uncaught ReferenceError", diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index 6654da9f809591e06161efe368a0cb427c461eba..a9700312e7ffd163e877dab4e28c96fec578e641 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -1,617 +1,60 @@ -import { - type FormEvent, - type MutableRefObject, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useEffect, useMemo, useRef } from "react"; -import type { - ProviderEvent, - ProviderKind, - ProviderSession, -} from "@acme/contracts"; +import ChatView from "./components/ChatView"; +import DiffPanel from "./components/DiffPanel"; +import Sidebar from "./components/Sidebar"; +import { readNativeApi } from "./session-logic"; +import { StoreProvider, useStore } from "./store"; -type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; - -interface ChatMessage { - id: string; - role: "user" | "assistant"; - text: string; - createdAt: string; - streaming: boolean; -} - -const PROVIDER_OPTIONS: Array<{ - value: ProviderKind; - label: string; - available: boolean; -}> = [ - { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code (soon)", available: false }, -]; - -function readNativeApi() { - if (typeof window === "undefined") { - return undefined; - } - - return window.nativeApi; -} - -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function formatTimestamp(isoDate: string): string { - return new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", - second: "2-digit", - }).format(new Date(isoDate)); -} - -function derivePhase(session: ProviderSession | null): SessionPhase { - if (!session || session.status === "closed") { - return "disconnected"; - } - - if (session.status === "connecting") { - return "connecting"; - } - - if (session.status === "running") { - return "running"; - } - - return "ready"; -} - -function evolveSession( - previous: ProviderSession, - event: ProviderEvent, -): ProviderSession { - const payload = asObject(event.payload); - - if (event.method === "thread/started") { - const thread = asObject(payload?.thread); - return { - ...previous, - threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId, - updatedAt: event.createdAt, - }; - } - - if (event.method === "turn/started") { - const turn = asObject(payload?.turn); - return { - ...previous, - status: "running", - activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId, - updatedAt: event.createdAt, - }; - } - - if (event.method === "turn/completed") { - const turn = asObject(payload?.turn); - const status = asString(turn?.status); - const turnError = asObject(turn?.error); - return { - ...previous, - status: status === "failed" ? "error" : "ready", - activeTurnId: undefined, - lastError: asString(turnError?.message) ?? previous.lastError, - updatedAt: event.createdAt, - }; - } - - if (event.kind === "error") { - return { - ...previous, - status: "error", - lastError: event.message ?? previous.lastError, - updatedAt: event.createdAt, - }; - } - - if (event.method === "session/closed" || event.method === "session/exited") { - return { - ...previous, - status: "closed", - activeTurnId: undefined, - lastError: event.message ?? previous.lastError, - updatedAt: event.createdAt, - }; - } - - return { - ...previous, - updatedAt: event.createdAt, - }; -} - -function applyEventToMessages( - previous: ChatMessage[], - event: ProviderEvent, - activeAssistantItemRef: MutableRefObject, -): ChatMessage[] { - const payload = asObject(event.payload); - - if (event.method === "item/started") { - const item = asObject(payload?.item); - if (asString(item?.type) !== "agentMessage") { - return previous; - } - - const itemId = asString(item?.id); - if (!itemId) { - return previous; - } - - activeAssistantItemRef.current = itemId; - const seedText = asString(item?.text) ?? ""; - const filtered = previous.filter((entry) => entry.id !== itemId); - return [ - ...filtered, - { - id: itemId, - role: "assistant", - text: seedText, - createdAt: event.createdAt, - streaming: true, - }, - ]; - } - - if (event.method === "item/agentMessage/delta") { - const itemId = event.itemId ?? asString(payload?.itemId); - const delta = event.textDelta ?? asString(payload?.delta) ?? ""; - if (!itemId || !delta) { - return previous; - } - - const existingIndex = previous.findIndex((entry) => entry.id === itemId); - if (existingIndex === -1) { - activeAssistantItemRef.current = itemId; - return [ - ...previous, - { - id: itemId, - role: "assistant", - text: delta, - createdAt: event.createdAt, - streaming: true, - }, - ]; - } - - const updated = [...previous]; - const existing = updated[existingIndex]; - if (!existing) { - return previous; - } - updated[existingIndex] = { - ...existing, - text: `${existing.text}${delta}`, - streaming: true, - }; - return updated; - } - - if (event.method === "item/completed") { - const item = asObject(payload?.item); - if (asString(item?.type) !== "agentMessage") { - return previous; - } - - const itemId = asString(item?.id); - if (!itemId) { - return previous; - } - - const fullText = asString(item?.text); - const existingIndex = previous.findIndex((entry) => entry.id === itemId); - if (existingIndex === -1) { - return [ - ...previous, - { - id: itemId, - role: "assistant", - text: fullText ?? "", - createdAt: event.createdAt, - streaming: false, - }, - ]; - } - - const updated = [...previous]; - const existing = updated[existingIndex]; - if (!existing) { - return previous; - } - updated[existingIndex] = { - ...existing, - text: fullText ?? existing.text, - streaming: false, - }; - - if (activeAssistantItemRef.current === itemId) { - activeAssistantItemRef.current = null; - } - - return updated; - } - - if (event.method === "turn/completed") { - return previous.map((entry) => ({ ...entry, streaming: false })); - } - - return previous; -} - -export default function App() { +function EventRouter() { const api = useMemo(() => readNativeApi(), []); - const [provider, setProvider] = useState("codex"); - const [cwd, setCwd] = useState(""); - const [model, setModel] = useState("gpt-5.1-codex"); - const [prompt, setPrompt] = useState(""); - const [session, setSession] = useState(null); - const [messages, setMessages] = useState([]); - const [events, setEvents] = useState([]); - const [isConnecting, setIsConnecting] = useState(false); - const [isSending, setIsSending] = useState(false); - const [error, setError] = useState(null); - - const currentSessionIdRef = useRef(null); + const { dispatch } = useStore(); const activeAssistantItemRef = useRef(null); useEffect(() => { - currentSessionIdRef.current = session?.sessionId ?? null; - }, [session?.sessionId]); - - useEffect(() => { - if (!api) { - return; - } - + if (!api) return; return api.providers.onEvent((event) => { - const currentSessionId = currentSessionIdRef.current; - if (!currentSessionId || currentSessionId !== event.sessionId) { - return; - } - - setEvents((previous) => [event, ...previous].slice(0, 200)); - setSession((previous) => { - if (!previous || previous.sessionId !== event.sessionId) { - return previous; - } - return evolveSession(previous, event); + dispatch({ + type: "APPLY_EVENT", + event, + activeAssistantItemRef, }); - setMessages((previous) => - applyEventToMessages(previous, event, activeAssistantItemRef), - ); - - if (event.kind === "error" && event.message) { - setError(event.message); - } }); - }, [api]); - - const phase = derivePhase(session); - - const onConnect = async (event: FormEvent) => { - event.preventDefault(); - if (!api || provider !== "codex" || isConnecting) { - return; - } - - setError(null); - setIsConnecting(true); - try { - const nextSession = await api.providers.startSession({ - provider, - cwd: cwd.trim() || undefined, - model: model.trim() || undefined, - }); - currentSessionIdRef.current = nextSession.sessionId; - activeAssistantItemRef.current = null; - setSession(nextSession); - setMessages([]); - setEvents([]); - } catch (connectError) { - setError( - connectError instanceof Error - ? connectError.message - : "Unable to connect to Codex app-server.", - ); - } finally { - setIsConnecting(false); - } - }; - - const onDisconnect = async () => { - if (!api || !session) { - return; - } + }, [api, dispatch]); - await api.providers.stopSession({ sessionId: session.sessionId }); - currentSessionIdRef.current = null; - setSession((previous) => - previous - ? { - ...previous, - status: "closed", - activeTurnId: undefined, - updatedAt: new Date().toISOString(), - } - : previous, - ); - }; + return null; +} - const onSendPrompt = async (event: FormEvent) => { - event.preventDefault(); - if (!api || !session || isSending) { - return; - } - - const trimmed = prompt.trim(); - if (!trimmed) { - return; - } - - setError(null); - setMessages((previous) => [ - ...previous, - { - id: crypto.randomUUID(), - role: "user", - text: trimmed, - createdAt: new Date().toISOString(), - streaming: false, - }, - ]); - setPrompt(""); - setIsSending(true); - - try { - await api.providers.sendTurn({ - sessionId: session.sessionId, - input: trimmed, - }); - } catch (sendError) { - setError( - sendError instanceof Error - ? sendError.message - : "Failed to start a Codex turn.", - ); - } finally { - setIsSending(false); - } - }; - - const onInterrupt = async () => { - if (!api || !session) { - return; - } - - await api.providers.interruptTurn({ - sessionId: session.sessionId, - turnId: session.activeTurnId, - }); - }; +function Layout() { + const api = useMemo(() => readNativeApi(), []); + const { state } = useStore(); if (!api) { return ( -
-
-

CodeThing

-

Native bridge unavailable

-

- Launch the renderer through Electron so `window.nativeApi` is - exposed by preload. +

+
+

+ Native bridge unavailable. Launch through Electron.

-
-
+ + ); } return ( -
-
-
-

CodeThing / Provider Shell

-

- Codex-first workspace -

-

- Built on the Codex app-server protocol with a provider abstraction in - the IPC layer, so Claude Code can be added without reshaping the UI - contract. -

- -
- - - - - - -
- {phase === "disconnected" ? ( - - ) : ( - - )} -
-
- -
- - phase={phase} provider={session?.provider ?? "none"} - - - thread={session?.threadId ?? "none"} - - - turn={session?.activeTurnId ?? "none"} - -
-
- - {error ? ( -
- {error} -
- ) : null} - -
-
-

Conversation

-
- {messages.length === 0 ? ( -
- No turns yet. Start a session, then send a prompt. -
- ) : null} - - {messages.map((entry) => ( -
-
- {entry.role === "user" ? "you" : "codex"} - {formatTimestamp(entry.createdAt)} -
-
-                  {entry.text || (entry.streaming ? "..." : "(empty response)")}
-                
-
- ))} -
- -
-