diff --git a/app-server-ui/.gitignore b/app-server-ui/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/app-server-ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/app-server-ui/README.md b/app-server-ui/README.md new file mode 100644 index 0000000000..265ea4dd43 --- /dev/null +++ b/app-server-ui/README.md @@ -0,0 +1,30 @@ +# Codex App Server UI + +Minimal React + Vite client for the codex app-server v2 JSON-RPC protocol. + +## Prerequisites + +- `codex` CLI available in your PATH (or set `CODEX_BIN`). +- If you are working from this repo, the bridge will prefer the local + `codex-rs/target/debug/codex-app-server` binary when it exists. +- A configured Codex environment (API key or login) as required by the app-server. + +## Quickstart + +From the repo root: + +```bash +pnpm install +pnpm --filter app-server-ui dev +``` + +This starts: +- a WebSocket bridge at `ws://localhost:8787` that spawns `codex app-server` +- the Vite dev server at `http://localhost:5173` + +## Configuration + +- `CODEX_BIN`: path to the `codex` executable (default: `codex`). +- `APP_SERVER_BIN` / `CODEX_APP_SERVER_BIN`: path to a `codex-app-server` binary (overrides `CODEX_BIN`). +- `APP_SERVER_UI_PORT`: port for the bridge server (default: `8787`). +- `VITE_APP_SERVER_WS`: WebSocket URL for the UI (default: `ws://localhost:8787`). diff --git a/app-server-ui/index.html b/app-server-ui/index.html new file mode 100644 index 0000000000..86a50ddf90 --- /dev/null +++ b/app-server-ui/index.html @@ -0,0 +1,12 @@ + + + + + + Codex App Server UI + + +
+ + + diff --git a/app-server-ui/package.json b/app-server-ui/package.json new file mode 100644 index 0000000000..6767e5bc08 --- /dev/null +++ b/app-server-ui/package.json @@ -0,0 +1,26 @@ +{ + "name": "app-server-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "concurrently -k \"pnpm:dev:server\" \"pnpm:dev:client\"", + "dev:client": "vite", + "dev:server": "node server/index.mjs", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^8.2.2", + "typescript": "~5.9.3", + "vite": "^7.2.4" + } +} diff --git a/app-server-ui/public/vite.svg b/app-server-ui/public/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/app-server-ui/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app-server-ui/server/index.mjs b/app-server-ui/server/index.mjs new file mode 100644 index 0000000000..a342816b0a --- /dev/null +++ b/app-server-ui/server/index.mjs @@ -0,0 +1,181 @@ +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { WebSocketServer } from "ws"; + +const port = Number(process.env.APP_SERVER_UI_PORT ?? 8787); +const codexBin = process.env.CODEX_BIN ?? "codex"; +const explicitAppServerBin = + process.env.APP_SERVER_BIN ?? process.env.CODEX_APP_SERVER_BIN ?? null; +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, "..", ".."); +const localAppServerBin = resolve(repoRoot, "codex-rs/target/debug/codex-app-server"); +const appServerBin = + explicitAppServerBin ?? (existsSync(localAppServerBin) ? localAppServerBin : null); + +const sockets = new Set(); +let appServer = null; + +const broadcast = (payload) => { + const message = JSON.stringify(payload); + for (const ws of sockets) { + if (ws.readyState === ws.OPEN) { + ws.send(message); + } + } +}; + +const startAppServer = () => { + if (appServer?.child?.exitCode === null) { + return appServer; + } + + const command = appServerBin ?? codexBin; + const args = appServerBin ? [] : ["app-server"]; + const child = spawn(command, args, { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }); + + const stdout = child.stdout; + const stderr = child.stderr; + const stdoutRl = stdout ? createInterface({ input: stdout }) : null; + const stderrRl = stderr ? createInterface({ input: stderr }) : null; + + stdoutRl?.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + + let payload = trimmed; + try { + const parsed = JSON.parse(trimmed); + payload = JSON.stringify(parsed); + } catch { + payload = JSON.stringify({ + method: "ui/raw", + params: { line: trimmed }, + }); + } + + for (const ws of sockets) { + if (ws.readyState === ws.OPEN) { + ws.send(payload); + } + } + }); + + stderrRl?.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + console.error(trimmed); + broadcast({ method: "ui/stderr", params: { line: trimmed } }); + }); + + child.on("error", (err) => { + console.error("codex app-server spawn error:", err); + broadcast({ method: "ui/error", params: { message: "Failed to spawn app-server.", details: String(err) } }); + appServer = null; + }); + + child.on("exit", (code, signal) => { + console.log(`codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"})`); + broadcast({ method: "ui/exit", params: { code, signal } }); + appServer = null; + }); + + appServer = { child, stdoutRl, stderrRl }; + return appServer; +}; + +const server = createServer((req, res) => { + if (req.url === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + return; + } + + res.writeHead(404, { "content-type": "text/plain" }); + res.end("Not found"); +}); + +const wss = new WebSocketServer({ server }); + +wss.on("connection", (ws) => { + sockets.add(ws); + const running = Boolean(appServer?.child?.exitCode === null); + ws.send( + JSON.stringify({ + method: "ui/connected", + params: { pid: appServer?.child?.pid ?? null, running }, + }), + ); + + ws.on("close", () => { + sockets.delete(ws); + }); + + ws.on("message", (data) => { + const text = typeof data === "string" ? data : data.toString("utf8"); + if (!text.trim()) { + return; + } + + let parsed; + try { + parsed = JSON.parse(text); + } catch (err) { + ws.send( + JSON.stringify({ + method: "ui/error", + params: { + message: "Failed to parse JSON from client.", + details: String(err), + }, + }), + ); + return; + } + + if (!appServer || appServer.child.exitCode !== null || !appServer.child.stdin?.writable) { + startAppServer(); + } + + if (!appServer || !appServer.child.stdin?.writable) { + ws.send( + JSON.stringify({ + method: "ui/error", + params: { + message: "app-server stdin is closed.", + }, + }), + ); + return; + } + + appServer.child.stdin.write(`${JSON.stringify(parsed)}\n`); + }); +}); + +server.listen(port, () => { + console.log(`App server bridge listening on ws://localhost:${port}`); +}); + +startAppServer(); + +const shutdown = () => { + appServer?.stdoutRl?.close(); + appServer?.stderrRl?.close(); + wss.close(); + server.close(); + appServer?.child?.kill("SIGTERM"); +}; + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/app-server-ui/src/App.css b/app-server-ui/src/App.css new file mode 100644 index 0000000000..c13f49e1bb --- /dev/null +++ b/app-server-ui/src/App.css @@ -0,0 +1,374 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap"); + +:root { + color-scheme: only light; + font-family: "Space Grotesk", "Segoe UI", system-ui, sans-serif; + color: #1f2a2e; + background-color: #f6f1ea; + --panel-bg: rgba(255, 255, 255, 0.88); + --panel-border: #e6d9cb; + --accent: #f36b3f; + --accent-2: #2f7d8b; + --text-muted: #5b6a72; + --shadow: 0 18px 45px rgba(72, 54, 42, 0.16); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(1200px 600px at 10% 0%, rgba(243, 107, 63, 0.12), transparent 70%), + radial-gradient(900px 520px at 100% 10%, rgba(47, 125, 139, 0.14), transparent 65%), + #f6f1ea; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + background-image: linear-gradient(120deg, rgba(31, 42, 46, 0.06) 0%, rgba(31, 42, 46, 0.02) 100%); + pointer-events: none; +} + +.app { + min-height: 100vh; + padding: 32px 40px 48px; + display: flex; + flex-direction: column; + gap: 28px; +} + +.hero { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 12px; + color: var(--text-muted); + margin: 0 0 8px; +} + +h1 { + font-size: clamp(28px, 4vw, 40px); + margin: 0 0 8px; +} + +.subtitle { + margin: 0; + max-width: 560px; + color: var(--text-muted); + font-size: 16px; +} + +.status { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 18px; + box-shadow: var(--shadow); + min-width: 240px; +} + +.status-dot { + width: 12px; + height: 12px; + border-radius: 999px; + background: #c2c2c2; +} + +.status-dot.on { + background: #3cb371; +} + +.status-dot.off { + background: #c46a5e; +} + +.status-label { + font-weight: 600; +} + +.status-meta { + font-size: 12px; + color: var(--text-muted); +} + +.grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); + gap: 24px; +} + +.panel { + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 24px; + padding: 24px; + box-shadow: var(--shadow); + backdrop-filter: blur(6px); +} + +.panel-title { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 12px; + color: var(--text-muted); + margin-bottom: 16px; +} + +.control { + display: flex; + flex-direction: column; + gap: 18px; +} + +.control-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +.value { + font-size: 14px; + font-family: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace; + margin-top: 4px; +} + +.button-row { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.btn { + border: 1px solid var(--panel-border); + background: white; + color: #1f2a2e; + padding: 10px 16px; + border-radius: 999px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.btn:hover:enabled { + transform: translateY(-1px); + box-shadow: 0 8px 18px rgba(31, 42, 46, 0.16); +} + +.btn.primary { + background: linear-gradient(120deg, var(--accent), #f89e6c); + border-color: transparent; + color: #1b1a19; +} + +.btn:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.notice { + padding: 12px 14px; + border-radius: 12px; + background: rgba(243, 107, 63, 0.12); + color: #9a3f1e; + font-size: 13px; +} + +.composer textarea { + width: 100%; + margin-top: 8px; + margin-bottom: 12px; + border-radius: 16px; + border: 1px solid var(--panel-border); + padding: 12px 14px; + font-family: inherit; + resize: vertical; + min-height: 110px; + background: rgba(255, 255, 255, 0.7); +} + +.thread-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.thread-item { + text-align: left; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.7); + font-family: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace; + font-size: 12px; + cursor: pointer; +} + +.thread-item:hover { + border-color: rgba(47, 125, 139, 0.4); +} + +.thread-item.active { + border-color: rgba(243, 107, 63, 0.6); + background: rgba(243, 107, 63, 0.08); +} + +.approvals { + display: flex; + flex-direction: column; + gap: 16px; +} + +.approval-card { + background: rgba(255, 255, 255, 0.95); + border: 1px dashed rgba(31, 42, 46, 0.2); + border-radius: 18px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.approval-header { + display: flex; + justify-content: space-between; + font-size: 13px; + color: var(--text-muted); +} + +.approval-time { + font-family: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace; +} + +.approval-card pre { + margin: 0; + padding: 12px; + border-radius: 12px; + background: #f7f1ea; + font-size: 12px; + font-family: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace; + overflow-x: auto; +} + +.chat { + display: flex; + flex-direction: column; + gap: 12px; +} + +.chat-scroll { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 520px; + overflow-y: auto; +} + +.bubble { + border-radius: 18px; + padding: 14px 16px; + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.92); +} + +.bubble.user { + align-self: flex-start; + border-color: rgba(47, 125, 139, 0.25); +} + +.bubble.assistant { + align-self: flex-end; + border-color: rgba(243, 107, 63, 0.3); +} + +.bubble-role { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + margin-bottom: 6px; +} + +.bubble-text { + white-space: pre-wrap; + line-height: 1.5; +} + +.logs { + display: flex; + flex-direction: column; +} + +.log-scroll { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 520px; + overflow-y: auto; + font-family: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace; + font-size: 12px; +} + +.log-entry { + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(31, 42, 46, 0.12); + background: rgba(255, 255, 255, 0.7); +} + +.log-entry.out { + border-color: rgba(47, 125, 139, 0.2); +} + +.log-meta { + display: flex; + justify-content: space-between; + margin-bottom: 4px; + color: var(--text-muted); +} + +.log-detail { + color: #1f2a2e; + word-break: break-word; +} + +.empty { + color: var(--text-muted); + font-size: 14px; +} + +@media (max-width: 980px) { + .hero { + flex-direction: column; + align-items: flex-start; + } + + .grid { + grid-template-columns: 1fr; + } + + .app { + padding: 24px; + } +} diff --git a/app-server-ui/src/App.tsx b/app-server-ui/src/App.tsx new file mode 100644 index 0000000000..bdd11d009e --- /dev/null +++ b/app-server-ui/src/App.tsx @@ -0,0 +1,712 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +type RpcError = { + message?: string; + [key: string]: unknown; +}; + +type RpcMessage = { + id?: number | string; + method?: string; + params?: Record; + result?: Record; + error?: RpcError; +}; + +type LogEntry = { + id: number; + direction: "in" | "out"; + label: string; + detail?: string; + time: string; +}; + +type ChatMessage = { + id: string; + role: "user" | "assistant"; + text: string; +}; + +type ApprovalRequest = { + id: number | string; + method: string; + params: Record; + receivedAt: string; +}; + +type PendingRequest = { + method: string; +}; + +const wsUrl = import.meta.env.VITE_APP_SERVER_WS ?? "ws://localhost:8787"; + +const formatTime = () => + new Date().toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + +const summarizeParams = (params: Record | undefined) => { + if (!params) { + return undefined; + } + + try { + return JSON.stringify(params); + } catch { + return "[unserializable params]"; + } +}; + +const shouldLogMethod = (method: string) => { + if (method.includes("/delta")) { + return false; + } + + return true; +}; + +export default function App() { + const wsRef = useRef(null); + const nextIdRef = useRef(1); + const pendingRef = useRef>(new Map()); + const agentIndexRef = useRef>>(new Map()); + const selectedThreadIdRef = useRef(null); + const userItemIdsRef = useRef>>(new Map()); + + const [connected, setConnected] = useState(false); + const [initialized, setInitialized] = useState(false); + const [threads, setThreads] = useState([]); + const [selectedThreadId, setSelectedThreadId] = useState(null); + const [activeTurnId, setActiveTurnId] = useState(null); + const [activeTurnThreadId, setActiveTurnThreadId] = useState(null); + const [input, setInput] = useState(""); + const [logs, setLogs] = useState([]); + const [threadMessages, setThreadMessages] = useState>({}); + const [approvals, setApprovals] = useState([]); + const [connectionError, setConnectionError] = useState(null); + + const pushLog = useCallback((entry: Omit) => { + setLogs((prev) => { + const next: LogEntry[] = [ + { + id: prev.length ? prev[0].id + 1 : 1, + time: formatTime(), + ...entry, + }, + ...prev, + ]; + return next.slice(0, 200); + }); + }, []); + + const sendPayload = useCallback( + (payload: RpcMessage, label?: string) => { + const socket = wsRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + + const json = JSON.stringify(payload); + socket.send(json); + pushLog({ + direction: "out", + label: label ?? payload.method ?? "response", + detail: summarizeParams(payload.params) ?? summarizeParams(payload.result), + }); + }, + [pushLog], + ); + + const sendRequest = useCallback( + (method: string, params?: Record) => { + const id = nextIdRef.current++; + pendingRef.current.set(id, { method }); + sendPayload({ id, method, params }, method); + return id; + }, + [sendPayload], + ); + + const sendNotification = useCallback( + (method: string, params?: Record) => { + sendPayload({ method, params }, method); + }, + [sendPayload], + ); + + const selectThread = useCallback((threadId: string | null) => { + selectedThreadIdRef.current = threadId; + setSelectedThreadId(threadId); + }, []); + + const ensureThread = useCallback( + (threadId: string) => { + setThreads((prev) => (prev.includes(threadId) ? prev : [...prev, threadId])); + setThreadMessages((prev) => (prev[threadId] ? prev : { ...prev, [threadId]: [] })); + if (!selectedThreadIdRef.current) { + selectThread(threadId); + } + }, + [selectThread], + ); + + const getAgentIndexForThread = useCallback((threadId: string) => { + const existing = agentIndexRef.current.get(threadId); + if (existing) { + return existing; + } + const next = new Map(); + agentIndexRef.current.set(threadId, next); + return next; + }, []); + + const handleInitialize = useCallback(() => { + sendRequest("initialize", { + clientInfo: { + name: "codex_app_server_ui", + title: "Codex App Server UI", + version: "0.1.0", + }, + }); + }, [sendRequest]); + + const handleStartThread = useCallback(() => { + if (!initialized) { + return; + } + + sendRequest("thread/start", {}); + }, [initialized, sendRequest]); + + const handleSendMessage = useCallback(() => { + if (!initialized || !selectedThreadId || !input.trim()) { + return; + } + + const text = input.trim(); + setInput(""); + + sendRequest("turn/start", { + threadId: selectedThreadId, + input: [{ type: "text", text }], + }); + }, [initialized, selectedThreadId, input, sendRequest]); + + const handleApprovalDecision = useCallback( + (approvalId: number | string, decision: "accept" | "decline") => { + sendPayload({ + id: approvalId, + result: { + decision, + }, + }); + + setApprovals((prev) => prev.filter((approval) => approval.id !== approvalId)); + }, + [sendPayload], + ); + + const updateAgentMessage = useCallback( + (threadId: string, itemId: string, delta: string) => { + setThreadMessages((prev) => { + const threadLog = prev[threadId] ?? []; + const indexMap = getAgentIndexForThread(threadId); + const existingIndex = indexMap.get(itemId); + if (existingIndex === undefined) { + indexMap.set(itemId, threadLog.length); + return { + ...prev, + [threadId]: [...threadLog, { id: itemId, role: "assistant", text: delta }], + }; + } + + const nextThreadLog = [...threadLog]; + nextThreadLog[existingIndex] = { + ...nextThreadLog[existingIndex], + text: nextThreadLog[existingIndex].text + delta, + }; + return { ...prev, [threadId]: nextThreadLog }; + }); + }, + [getAgentIndexForThread], + ); + + const extractUserText = useCallback((content: unknown) => { + if (!Array.isArray(content)) { + return null; + } + const parts = content + .map((entry) => { + if (entry && typeof entry === "object" && (entry as { type?: string }).type === "text") { + return (entry as { text?: string }).text ?? ""; + } + return ""; + }) + .filter((text) => text.length > 0); + return parts.length ? parts.join("\n") : null; + }, []); + + const markUserItemSeen = useCallback((threadId: string, itemId: string) => { + const seen = userItemIdsRef.current.get(threadId) ?? new Set(); + if (!userItemIdsRef.current.has(threadId)) { + userItemIdsRef.current.set(threadId, seen); + } + if (seen.has(itemId)) { + return false; + } + seen.add(itemId); + return true; + }, []); + + const handleIncomingMessage = useCallback( + (message: RpcMessage) => { + if (message.id !== undefined && message.method) { + const requestId = message.id; + if ( + message.method === "item/commandExecution/requestApproval" || + message.method === "item/fileChange/requestApproval" + ) { + setApprovals((prev) => [ + ...prev, + { + id: requestId, + method: message.method ?? "", + params: message.params ?? {}, + receivedAt: formatTime(), + }, + ]); + pushLog({ + direction: "in", + label: message.method, + detail: summarizeParams(message.params), + }); + } + return; + } + + if (message.id !== undefined) { + const pending = pendingRef.current.get(message.id); + pendingRef.current.delete(message.id); + + if (pending) { + if (pending.method === "initialize") { + const errorMessage = + message.error && typeof message.error.message === "string" + ? message.error.message + : null; + const alreadyInitialized = errorMessage === "Already initialized"; + if (!message.error) { + sendNotification("initialized"); + } + if (!message.error || alreadyInitialized) { + setInitialized(true); + sendRequest("thread/loaded/list"); + } + } + + if (pending.method === "thread/start" || pending.method === "thread/resume") { + const thread = message.result?.thread as { id?: string } | undefined; + if (thread?.id) { + ensureThread(thread.id); + } + } + + if (pending.method === "thread/loaded/list") { + const data = message.result?.data; + if (Array.isArray(data)) { + const ids = data.filter((entry): entry is string => typeof entry === "string"); + setThreads(ids); + setThreadMessages((prev) => { + const next = { ...prev }; + for (const id of ids) { + if (!next[id]) { + next[id] = []; + } + } + return next; + }); + if (!selectedThreadIdRef.current && ids.length > 0) { + selectThread(ids[0]); + } + } + } + + if (pending.method === "turn/start") { + const turn = message.result?.turn as { id?: string } | undefined; + if (turn?.id) { + setActiveTurnId(turn.id); + setActiveTurnThreadId(selectedThreadIdRef.current); + } + } + } + + pushLog({ + direction: "in", + label: pending?.method ?? "response", + detail: summarizeParams(message.result) ?? summarizeParams(message.error), + }); + return; + } + + if (message.method) { + const eventThreadId = message.params?.threadId as string | undefined; + if (eventThreadId) { + ensureThread(eventThreadId); + } + + if (shouldLogMethod(message.method)) { + pushLog({ + direction: "in", + label: message.method, + detail: summarizeParams(message.params), + }); + } + + if (message.method === "thread/started") { + const thread = (message.params?.thread as { id?: string } | undefined) ?? undefined; + if (thread?.id) { + ensureThread(thread.id); + } + } + + if (message.method === "turn/started") { + const turn = (message.params?.turn as { id?: string } | undefined) ?? undefined; + const threadId = message.params?.threadId as string | undefined; + if (turn?.id) { + setActiveTurnId(turn.id); + setActiveTurnThreadId(threadId ?? null); + } + } + + if (message.method === "turn/completed") { + setActiveTurnId(null); + setActiveTurnThreadId(null); + } + + if (message.method === "item/started") { + const item = message.params?.item as { + id?: string; + type?: string; + content?: unknown; + text?: string; + } | undefined; + const threadId = message.params?.threadId as string | undefined; + if (!threadId) { + return; + } + const itemId = item?.id; + if (item?.type === "agentMessage" && itemId) { + setThreadMessages((prev) => { + const threadLog = prev[threadId] ?? []; + const indexMap = getAgentIndexForThread(threadId); + indexMap.set(itemId, threadLog.length); + return { + ...prev, + [threadId]: [...threadLog, { id: itemId, role: "assistant", text: "" }], + }; + }); + } + + if (item?.type === "userMessage") { + const userText = extractUserText(item.content); + if (userText) { + if (itemId && !markUserItemSeen(threadId, itemId)) { + return; + } + setThreadMessages((prev) => { + const threadLog = prev[threadId] ?? []; + return { + ...prev, + [threadId]: [ + ...threadLog, + { id: itemId ?? `user-${Date.now()}`, role: "user", text: userText }, + ], + }; + }); + } + } + } + + if (message.method === "item/agentMessage/delta") { + const itemId = message.params?.itemId as string | undefined; + const threadId = message.params?.threadId as string | undefined; + const delta = message.params?.delta as string | undefined; + if (itemId && delta && threadId) { + updateAgentMessage(threadId, itemId, delta); + } + } + + if (message.method === "item/completed") { + const item = message.params?.item as { + id?: string; + type?: string; + text?: string; + content?: unknown; + } | undefined; + const threadId = message.params?.threadId as string | undefined; + if (!threadId) { + return; + } + const itemId = item?.id; + if (item?.type === "agentMessage" && itemId && typeof item.text === "string") { + setThreadMessages((prev) => { + const threadLog = prev[threadId] ?? []; + const index = getAgentIndexForThread(threadId).get(itemId); + if (index === undefined) { + getAgentIndexForThread(threadId).set(itemId, threadLog.length); + return { + ...prev, + [threadId]: [...threadLog, { id: itemId, role: "assistant", text: item.text ?? "" }], + }; + } + + const nextThreadLog = [...threadLog]; + nextThreadLog[index] = { ...nextThreadLog[index], text: item.text ?? "" }; + return { ...prev, [threadId]: nextThreadLog }; + }); + } + + if (item?.type === "userMessage") { + return; + } + } + } + }, + [ + ensureThread, + extractUserText, + getAgentIndexForThread, + markUserItemSeen, + pushLog, + sendNotification, + selectThread, + sendRequest, + updateAgentMessage, + ], + ); + + const connect = useCallback(() => { + if (wsRef.current) { + wsRef.current.close(); + } + + const socket = new WebSocket(wsUrl); + wsRef.current = socket; + + socket.onopen = () => { + setConnected(true); + setConnectionError(null); + handleInitialize(); + }; + + socket.onclose = () => { + setConnected(false); + setInitialized(false); + setThreads([]); + selectThread(null); + setActiveTurnId(null); + setActiveTurnThreadId(null); + setApprovals([]); + setThreadMessages({}); + agentIndexRef.current.clear(); + userItemIdsRef.current.clear(); + pendingRef.current.clear(); + }; + + socket.onerror = () => { + setConnectionError("WebSocket error. Check the bridge server."); + }; + + socket.onmessage = (event) => { + try { + const parsed = JSON.parse(event.data as string) as RpcMessage; + handleIncomingMessage(parsed); + } catch (err) { + pushLog({ + direction: "in", + label: "ui/error", + detail: `Failed to parse message: ${String(err)}`, + }); + } + }; + }, [handleIncomingMessage, handleInitialize, pushLog, selectThread]); + + useEffect(() => { + connect(); + + return () => { + wsRef.current?.close(); + wsRef.current = null; + }; + }, [connect]); + + const statusLabel = useMemo(() => { + if (!connected) { + return "Disconnected"; + } + + if (!initialized) { + return "Connecting"; + } + + return "Ready"; + }, [connected, initialized]); + + const activeMessages = selectedThreadId ? threadMessages[selectedThreadId] ?? [] : []; + const displayedTurnId = + selectedThreadId && activeTurnThreadId === selectedThreadId ? activeTurnId : null; + + return ( +
+
+
+

codex app-server v2

+

Minimal Control Surface

+

+ A small React + Vite client wired to the JSON-RPC v2 protocol for threads and turns. +

+
+
+ +
+
{statusLabel}
+
{wsUrl}
+
+
+
+ +
+
+
Session
+
+
+
Thread
+
{selectedThreadId ?? "none"}
+
+
+
Turn
+
{displayedTurnId ?? "idle"}
+
+
+
+ + +
+ {connectionError ?
{connectionError}
: null} + +
+ +