diff --git a/AGENTS.md b/AGENTS.md index 8edd09be99ca44e5806d4ad5e0e4ae4b8487b020..7f5148fce0f2d5a09e6db4f11afc5d59ca8a0a5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ If a tradeoff is required, choose correctness and robustness over short-term con ## Package Roles - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. -- `apps/renderer`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. +- `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. - `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. ## Codex App Server (Important) @@ -24,7 +24,7 @@ How we use it in this codebase: - Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`. - Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`. - WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`. -- Renderer consumes provider event streams via WebSocket push on channel `providers.event`. +- Web app consumes provider event streams via WebSocket push on channel `providers.event`. Docs: - Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server diff --git a/README.md b/README.md index 6f90c7ad7e30fce036e7ca61735a65206305f574..1f18c9d30017780e9b4f1b3dd6ae7ede3cde0db8 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS ## Workspace layout -- `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built renderer, and opens the browser on start. -- `/apps/renderer`: React + Vite UI. Session control, conversation, and provider event rendering. Connects to the server via WebSocket. -- `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared renderer. +- `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built web app, and opens the browser on start. +- `/apps/web`: React + Vite UI. Session control, conversation, and provider event rendering. Connects to the server via WebSocket. +- `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared web app. - `/packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. ## Codex prerequisites @@ -61,9 +61,9 @@ npx t3 - `bun run dev` — Starts contracts, server, and web dev tasks via Turborepo's parallel task runner. - `bun run dev:server` — Starts just the WebSocket server (uses tsx for TS execution). -- `bun run dev:web` — Starts just the Vite dev server for the renderer. -- `bun run start` — Runs the production server (serves built renderer as static files). -- `bun run build` — Builds contracts, renderer, and server through Turbo. +- `bun run dev:web` — Starts just the Vite dev server for the web app. +- `bun run start` — Runs the production server (serves built web app as static files). +- `bun run build` — Builds contracts, web app, and server through Turbo. - `bun run typecheck` — Strict TypeScript checks for all packages. - `bun run test` — Runs workspace tests. @@ -76,7 +76,7 @@ T3 Code has a global runtime mode switch in the chat toolbar: ## Provider architecture -The renderer communicates with the server via WebSocket using a simple JSON-RPC-style protocol: +The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: - **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` - **Push events**: `{ type: "push", channel, data }` for streaming provider events diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7cfe513719291b415fdb4f7bfa871ce26323e586..9fc5d44ac8fb9b62b38f6e19e2bc5653c66b6d7d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -4,7 +4,7 @@ "private": true, "main": "dist-electron/main.js", "scripts": { - "dev": "bun run --cwd ../server build && concurrently -k -n BUNDLE,RENDERER,ELECTRON \"bun run dev:bundle\" \"bun run --cwd ../renderer dev\" \"bun run dev:electron\"", + "dev": "bun run --cwd ../server build && concurrently -k -n BUNDLE,WEB,ELECTRON \"bun run dev:bundle\" \"bun run --cwd ../web dev\" \"bun run dev:electron\"", "dev:bundle": "tsup --watch", "dev:electron": "bun run scripts/dev-electron.mjs", "build": "tsup", diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index cc13d469315277e9cc1f60f1fac85835eadb4158..895d18b8b60fa1033821432038aaeccdf7eaa481 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -8,7 +8,7 @@ const rootDir = resolve(__dirname, "../../.."); const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); const mainJs = resolve(desktopDir, "dist-electron/main.js"); -console.log("Building contracts + renderer + server + desktop..."); +console.log("Building contracts + web + server + desktop..."); execSync("bun run build", { cwd: rootDir, stdio: "inherit" }); console.log("\nLaunching Electron smoke test..."); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3cc451911b1325097a773ac59674fc03ae4aeb19..3161a2e9dc1d46e24665ea70d14ca7ec09b6933c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -14,7 +14,7 @@ fixPath(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const ROOT_DIR = path.resolve(__dirname, "../../.."); const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js"); -const RENDERER_ENTRY = path.join(ROOT_DIR, "apps/renderer/dist/index.html"); +const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); @@ -167,10 +167,10 @@ function createWindow(): BrowserWindow { if (isDevelopment) { void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); } else { - if (!fs.existsSync(RENDERER_ENTRY)) { - throw new Error(`Renderer bundle missing at ${RENDERER_ENTRY}`); + if (!fs.existsSync(WEB_ENTRY)) { + throw new Error(`Web bundle missing at ${WEB_ENTRY}`); } - void window.loadFile(RENDERER_ENTRY); + void window.loadFile(WEB_ENTRY); } window.on("closed", () => { diff --git a/apps/renderer/index.html b/apps/renderer/index.html deleted file mode 100644 index fe848c5287a8cb351c508d4b9696309f0b1f8c86..0000000000000000000000000000000000000000 --- a/apps/renderer/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - T3 Code - - -
- - - diff --git a/apps/renderer/package.json b/apps/renderer/package.json deleted file mode 100644 index 0c04817c874680ec04c52b3e1952c0add18d7275..0000000000000000000000000000000000000000 --- a/apps/renderer/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@t3tools/renderer", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" - }, - "dependencies": { - "@t3tools/contracts": "workspace:*", - "highlight.js": "^11.11.1", - "lucide-react": "^0.563.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-markdown": "^10.1.0", - "rehype-highlight": "^7.0.2", - "remark-gfm": "^4.0.1", - "zod": "^3.24.1" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.0.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^4.3.4", - "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", - "tailwindcss": "^4.0.0", - "typescript": "^5.7.3", - "vite": "^6.0.5" - } -} diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx deleted file mode 100644 index e12d563ab92d89ea62c4844ec0ecb8a527e80d4d..0000000000000000000000000000000000000000 --- a/apps/renderer/src/App.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; - -import ChatView from "./components/ChatView"; -import DiffPanel from "./components/DiffPanel"; -import Sidebar from "./components/Sidebar"; -import { isElectron } from "./env"; -import { DEFAULT_MODEL } from "./model-logic"; -import { readNativeApi } from "./session-logic"; -import { StoreProvider, useStore } from "./store"; -import { onServerWelcome } from "./wsNativeApi"; - -function EventRouter() { - const api = useMemo(() => readNativeApi(), []); - const { dispatch } = useStore(); - const activeAssistantItemRef = useRef(null); - - useEffect(() => { - if (!api) return; - return api.providers.onEvent((event) => { - dispatch({ - type: "APPLY_EVENT", - event, - activeAssistantItemRef, - }); - }); - }, [api, dispatch]); - - return null; -} - -function AutoProjectBootstrap() { - const { state, dispatch } = useStore(); - const bootstrappedRef = useRef(false); - - useEffect(() => { - // Browser mode bootstraps from server welcome. - // Electron bootstraps from persisted projects via DesktopProjectBootstrap. - if (isElectron) return; - - return onServerWelcome((payload) => { - if (bootstrappedRef.current) return; - - // Don't create duplicate projects for the same cwd - const existing = state.projects.find((p) => p.cwd === payload.cwd); - if (existing) { - bootstrappedRef.current = true; - // Ensure a thread is active - const existingThread = state.threads.find((t) => t.projectId === existing.id); - if (existingThread && !state.activeThreadId) { - dispatch({ - type: "SET_ACTIVE_THREAD", - threadId: existingThread.id, - }); - } - return; - } - - bootstrappedRef.current = true; - - // Create project + thread from server cwd - const projectId = crypto.randomUUID(); - dispatch({ - type: "ADD_PROJECT", - project: { - id: projectId, - name: payload.projectName, - cwd: payload.cwd, - model: DEFAULT_MODEL, - expanded: true, - }, - }); - dispatch({ - type: "ADD_THREAD", - thread: { - id: crypto.randomUUID(), - codexThreadId: null, - projectId, - title: "New thread", - model: DEFAULT_MODEL, - session: null, - messages: [], - events: [], - error: null, - createdAt: new Date().toISOString(), - }, - }); - }); - }, [state.projects, state.threads, state.activeThreadId, dispatch]); - - return null; -} - -function DesktopProjectBootstrap() { - const api = useMemo(() => readNativeApi(), []); - const { dispatch } = useStore(); - const bootstrappedRef = useRef(false); - - useEffect(() => { - if (!isElectron || !api || bootstrappedRef.current) return; - - let disposed = false; - let retryDelayMs = 500; - let retryTimer: ReturnType | null = null; - - const attemptBootstrap = async () => { - try { - const projects = await api.projects.list(); - if (disposed) return; - dispatch({ - type: "SYNC_PROJECTS", - projects: projects.map((project) => ({ - id: project.id, - name: project.name, - cwd: project.cwd, - model: DEFAULT_MODEL, - expanded: true, - })), - }); - bootstrappedRef.current = true; - } catch { - if (disposed) return; - retryTimer = setTimeout(() => { - retryTimer = null; - void attemptBootstrap(); - }, retryDelayMs); - retryDelayMs = Math.min(retryDelayMs * 2, 5_000); - } - }; - - void attemptBootstrap(); - - return () => { - disposed = true; - if (retryTimer) { - clearTimeout(retryTimer); - } - }; - }, [api, dispatch]); - - return null; -} - -function Layout() { - const api = useMemo(() => readNativeApi(), []); - const { state } = useStore(); - - if (!api) { - return ( -
-
-

Connecting to T3 Code server...

-
-
- ); - } - - return ( -
- - - - - - {state.diffOpen && } -
- ); -} - -export default function App() { - return ( - - - - ); -} diff --git a/apps/renderer/src/components/ChatMarkdown.tsx b/apps/renderer/src/components/ChatMarkdown.tsx deleted file mode 100644 index 5104ddced5e66cfd9c8d77207f41406f36bdf69d..0000000000000000000000000000000000000000 --- a/apps/renderer/src/components/ChatMarkdown.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Components } from "react-markdown"; -import ReactMarkdown from "react-markdown"; -import rehypeHighlight from "rehype-highlight"; -import remarkGfm from "remark-gfm"; - -interface ChatMarkdownProps { - text: string; -} - -const markdownComponents: Components = { - a({ node: _node, ...props }) { - return ; - }, -}; - -export default function ChatMarkdown({ text }: ChatMarkdownProps) { - return ( -
- - {text} - -
- ); -} diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx deleted file mode 100644 index 3c28c7da551713933e03debef0d306ea76c1c8b0..0000000000000000000000000000000000000000 --- a/apps/renderer/src/components/ChatView.tsx +++ /dev/null @@ -1,1170 +0,0 @@ -import { - PROVIDER_SEND_TURN_MAX_INPUT_CHARS, - type ProviderApprovalDecision, - type ProviderEvent, -} from "@t3tools/contracts"; -import { - type FormEvent, - Fragment, - type KeyboardEvent, - useEffect, - useMemo, - useRef, - useState, -} from "react"; - -import { EDITORS, type EditorId } from "@t3tools/contracts"; -import { isElectron } from "../env"; -import { buildBootstrapInput } from "../historyBootstrap"; -import { - DEFAULT_MODEL, - DEFAULT_REASONING, - MODEL_OPTIONS, - REASONING_OPTIONS, - resolveModelSlug, -} from "../model-logic"; -import { - derivePhase, - deriveTimelineEntries, - deriveWorkLogEntries, - formatDuration, - formatElapsed, - formatTimestamp, - readNativeApi, -} from "../session-logic"; -import { useStore } from "../store"; -import ChatMarkdown from "./ChatMarkdown"; - -function formatMessageMeta(createdAt: string, duration: string | null): string { - if (!duration) return formatTimestamp(createdAt); - return `${formatTimestamp(createdAt)} • ${duration}`; -} - -const FILE_MANAGER_LABEL = navigator.platform.includes("Mac") - ? "Finder" - : navigator.platform.includes("Win") - ? "Explorer" - : "Files"; - -function editorLabel(editor: (typeof EDITORS)[number]): string { - return editor.command ? editor.label : FILE_MANAGER_LABEL; -} - -const LAST_EDITOR_KEY = "t3code:last-editor"; -const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; - -function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { - if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; - if (tone === "tool") return "text-muted-foreground/70"; - if (tone === "thinking") return "text-muted-foreground/50"; - return "text-muted-foreground/40"; -} - -interface PendingApprovalCard { - requestId: string; - requestKind: "command" | "file-change"; - createdAt: string; - detail?: string; -} - -type SessionContinuityState = "resumed" | "new" | "fallback_new"; - -interface EnsuredSessionInfo { - sessionId: string; - resolvedThreadId: string | null; - continuityState: SessionContinuityState; -} - -function asRecord(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 approvalDetail(event: ProviderEvent): string | undefined { - const payload = asRecord(event.payload); - const command = asString(payload?.command); - if (command) return command; - return asString(payload?.reason); -} - -function derivePendingApprovals( - events: ProviderEvent[], -): PendingApprovalCard[] { - const pending = new Map(); - const ordered = [...events].toReversed(); - - for (const event of ordered) { - if ( - event.method === "session/closed" || - event.method === "session/exited" - ) { - pending.clear(); - continue; - } - - const requestId = - event.requestId ?? asString(asRecord(event.payload)?.requestId); - if (!requestId) continue; - - if ( - event.kind === "request" && - (event.requestKind === "command" || event.requestKind === "file-change") - ) { - const detail = approvalDetail(event); - pending.set(requestId, { - requestId, - requestKind: event.requestKind, - createdAt: event.createdAt, - ...(detail ? { detail } : {}), - }); - continue; - } - - if (event.method === "item/requestApproval/decision") { - pending.delete(requestId); - } - } - - return Array.from(pending.values()); -} - -export default function ChatView() { - const { state, dispatch } = useStore(); - const api = useMemo(() => readNativeApi(), []); - const [prompt, setPrompt] = useState(""); - const [isSending, setIsSending] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); - const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); - const [lastEditor, setLastEditor] = useState(() => { - const stored = localStorage.getItem(LAST_EDITOR_KEY); - return EDITORS.some((e) => e.id === stored) - ? (stored as EditorId) - : EDITORS[0].id; - }); - const [selectedEffort, setSelectedEffort] = - useState(DEFAULT_REASONING); - const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); - const [respondingRequestIds, setRespondingRequestIds] = useState( - [], - ); - const [expandedWorkGroups, setExpandedWorkGroups] = useState< - Record - >({}); - const [nowTick, setNowTick] = useState(() => Date.now()); - const messagesEndRef = useRef(null); - const textareaRef = useRef(null); - const modelMenuRef = useRef(null); - const editorMenuRef = useRef(null); - - const activeThread = state.threads.find((t) => t.id === state.activeThreadId); - const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); - const selectedModel = resolveModelSlug( - activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, - ); - const phase = derivePhase(activeThread?.session ?? null); - const isWorking = phase === "running" || isSending || isConnecting; - const nowIso = new Date(nowTick).toISOString(); - const modelOptions = MODEL_OPTIONS; - const workLogEntries = useMemo( - () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), - [activeThread?.events], - ); - const latestTurnWorkEntries = useMemo( - () => - deriveWorkLogEntries( - activeThread?.events ?? [], - activeThread?.latestTurnId, - ), - [activeThread?.events, activeThread?.latestTurnId], - ); - const pendingApprovals = useMemo( - () => derivePendingApprovals(activeThread?.events ?? []), - [activeThread?.events], - ); - const assistantCompletionByItemId = useMemo(() => { - const map = new Map(); - const ordered = [...(activeThread?.events ?? [])].toReversed(); - for (const event of ordered) { - if (event.method !== "item/completed") continue; - if (!event.itemId) continue; - map.set(event.itemId, event.createdAt); - } - return map; - }, [activeThread?.events]); - const timelineEntries = useMemo( - () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), - [activeThread?.messages, workLogEntries], - ); - const completionSummary = useMemo(() => { - if (!activeThread?.latestTurnStartedAt) return null; - if (!activeThread.latestTurnCompletedAt) return null; - if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) { - return null; - } - - if ( - typeof activeThread.latestTurnDurationMs === "number" && - Number.isFinite(activeThread.latestTurnDurationMs) && - activeThread.latestTurnDurationMs >= 0 - ) { - return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`; - } - - const elapsed = formatElapsed( - activeThread.latestTurnStartedAt, - activeThread.latestTurnCompletedAt, - ); - return elapsed ? `Worked for ${elapsed}` : null; - }, [ - activeThread?.latestTurnStartedAt, - activeThread?.latestTurnCompletedAt, - activeThread?.latestTurnDurationMs, - latestTurnWorkEntries, - ]); - const completionDividerBeforeEntryId = useMemo(() => { - if (!activeThread?.latestTurnStartedAt) return null; - if (!activeThread.latestTurnCompletedAt) return null; - if (!completionSummary) return null; - - const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); - const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); - if (Number.isNaN(turnStartedAt)) return null; - if (Number.isNaN(turnCompletedAt)) return null; - - let inRangeMatch: string | null = null; - let fallbackMatch: string | null = null; - for (const timelineEntry of timelineEntries) { - if (timelineEntry.kind !== "message") continue; - if (timelineEntry.message.role !== "assistant") continue; - const messageAt = Date.parse(timelineEntry.message.createdAt); - if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue; - fallbackMatch = timelineEntry.id; - if (messageAt <= turnCompletedAt) { - inRangeMatch = timelineEntry.id; - } - } - return inRangeMatch ?? fallbackMatch; - }, [ - activeThread?.latestTurnCompletedAt, - activeThread?.latestTurnStartedAt, - completionSummary, - timelineEntries, - ]); - const runtimeSessionConfig = - state.runtimeMode === "full-access" - ? ({ - approvalPolicy: "never", - sandboxMode: "danger-full-access", - } as const) - : ({ - approvalPolicy: "on-request", - sandboxMode: "workspace-write", - } as const); - - const handleRuntimeModeChange = async ( - mode: "approval-required" | "full-access", - ) => { - if (mode === state.runtimeMode) return; - dispatch({ type: "SET_RUNTIME_MODE", mode }); - if (!api) return; - - const sessionIds = state.threads - .map((t) => t.session) - .filter( - (s): s is NonNullable => s !== null && s.status !== "closed", - ) - .map((s) => s.sessionId); - - if (sessionIds.length === 0) return; - - setIsSwitchingRuntimeMode(true); - try { - await Promise.all( - sessionIds.map((id) => - api.providers.stopSession({ sessionId: id }).catch(() => undefined), - ), - ); - } finally { - setIsSwitchingRuntimeMode(false); - } - }; - - // Auto-scroll on new messages - const messageCount = activeThread?.messages.length ?? 0; - const workLogCount = workLogEntries.length; - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messageCount]); - useEffect(() => { - if (phase !== "running") return; - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [phase, workLogCount]); - - useEffect(() => { - setExpandedWorkGroups({}); - }, [activeThread?.id]); - - // Auto-resize textarea - useEffect(() => { - const ta = textareaRef.current; - if (!ta) return; - ta.style.height = "auto"; - ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; - }, [prompt]); - - useEffect(() => { - if (phase !== "running") return; - const timer = window.setInterval(() => { - setNowTick(Date.now()); - }, 250); - return () => { - window.clearInterval(timer); - }; - }, [phase]); - - useEffect(() => { - if (!isModelMenuOpen) return; - - const handleClickOutside = (event: MouseEvent) => { - if (!modelMenuRef.current) return; - if (event.target instanceof Node && !modelMenuRef.current.contains(event.target)) { - setIsModelMenuOpen(false); - } - }; - - window.addEventListener("mousedown", handleClickOutside); - return () => { - window.removeEventListener("mousedown", handleClickOutside); - }; - }, [isModelMenuOpen]); - - useEffect(() => { - if (!isEditorMenuOpen) return; - - const handleClickOutside = (event: MouseEvent) => { - if (!editorMenuRef.current) return; - if ( - event.target instanceof Node && - !editorMenuRef.current.contains(event.target) - ) { - setIsEditorMenuOpen(false); - } - }; - - window.addEventListener("mousedown", handleClickOutside); - return () => { - window.removeEventListener("mousedown", handleClickOutside); - }; - }, [isEditorMenuOpen]); - - // Cmd+O / Ctrl+O to open in last-used editor - useEffect(() => { - const handler = (e: globalThis.KeyboardEvent) => { - if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { - if (api && activeProject) { - e.preventDefault(); - void api.shell.openInEditor(activeProject.cwd, lastEditor); - } - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [api, activeProject, lastEditor]); - - const openInEditor = (editorId: EditorId) => { - if (!api || !activeProject) return; - void api.shell.openInEditor(activeProject.cwd, editorId); - setLastEditor(editorId); - localStorage.setItem(LAST_EDITOR_KEY, editorId); - setIsEditorMenuOpen(false); - }; - - const ensureSession = async (): Promise => { - if (!api || !activeThread || !activeProject) return null; - if (activeThread.session && activeThread.session.status !== "closed") { - const sessionThreadId = activeThread.session.threadId ?? null; - const continuityState: SessionContinuityState = - activeThread.codexThreadId === null - ? "new" - : sessionThreadId === activeThread.codexThreadId - ? "resumed" - : "fallback_new"; - return { - sessionId: activeThread.session.sessionId, - resolvedThreadId: sessionThreadId, - continuityState, - } satisfies EnsuredSessionInfo; - } - - const priorCodexThreadId = activeThread.codexThreadId; - setIsConnecting(true); - try { - const session = await api.providers.startSession({ - provider: "codex", - cwd: activeProject.cwd || undefined, - model: selectedModel || undefined, - resumeThreadId: priorCodexThreadId ?? undefined, - approvalPolicy: runtimeSessionConfig.approvalPolicy, - sandboxMode: runtimeSessionConfig.sandboxMode, - }); - dispatch({ - type: "UPDATE_SESSION", - threadId: activeThread.id, - session, - }); - const resolvedThreadId = session.threadId ?? null; - const continuityState: SessionContinuityState = - priorCodexThreadId === null - ? "new" - : resolvedThreadId === priorCodexThreadId - ? "resumed" - : "fallback_new"; - return { - sessionId: session.sessionId, - resolvedThreadId, - continuityState, - }; - } catch (err) { - dispatch({ - type: "SET_ERROR", - threadId: activeThread.id, - error: err instanceof Error ? err.message : "Failed to connect.", - }); - return null; - } finally { - setIsConnecting(false); - } - }; - - const onSend = async (e: FormEvent) => { - e.preventDefault(); - if (!api || !activeThread || isSending || isConnecting) return; - const trimmed = prompt.trim(); - if (!trimmed) return; - - // Auto-title from first message - if (activeThread.messages.length === 0) { - const title = trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed; - dispatch({ - type: "SET_THREAD_TITLE", - threadId: activeThread.id, - title, - }); - } - - dispatch({ - type: "SET_ERROR", - threadId: activeThread.id, - error: null, - }); - dispatch({ - type: "PUSH_USER_MESSAGE", - threadId: activeThread.id, - id: crypto.randomUUID(), - text: trimmed, - }); - const previousMessages = activeThread.messages; - setPrompt(""); - - const sessionInfo = await ensureSession(); - if (!sessionInfo) return; - - setIsSending(true); - try { - const shouldBootstrap = - previousMessages.length > 0 && - (sessionInfo.continuityState === "new" || - sessionInfo.continuityState === "fallback_new"); - const input = shouldBootstrap - ? buildBootstrapInput( - previousMessages, - trimmed, - PROVIDER_SEND_TURN_MAX_INPUT_CHARS, - ).text - : trimmed; - await api.providers.sendTurn({ - sessionId: sessionInfo.sessionId, - input, - model: selectedModel || undefined, - effort: selectedEffort || undefined, - }); - } catch (err) { - dispatch({ - type: "SET_ERROR", - threadId: activeThread.id, - error: err instanceof Error ? err.message : "Failed to send message.", - }); - } finally { - setIsSending(false); - } - }; - - const onInterrupt = async () => { - if (!api || !activeThread?.session) return; - await api.providers.interruptTurn({ - sessionId: activeThread.session.sessionId, - turnId: activeThread.session.activeTurnId, - }); - }; - - const onRespondToApproval = async ( - requestId: string, - decision: ProviderApprovalDecision, - ) => { - if (!api || !activeThread?.session) return; - - setRespondingRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], - ); - try { - await api.providers.respondToRequest({ - sessionId: activeThread.session.sessionId, - requestId, - decision, - }); - } catch (err) { - dispatch({ - type: "SET_ERROR", - threadId: activeThread.id, - error: - err instanceof Error - ? err.message - : "Failed to submit approval decision.", - }); - } finally { - setRespondingRequestIds((existing) => - existing.filter((id) => id !== requestId), - ); - } - }; - - const onModelSelect = (model: string) => { - if (!activeThread) return; - dispatch({ - type: "SET_THREAD_MODEL", - threadId: activeThread.id, - model: resolveModelSlug(model), - }); - setIsModelMenuOpen(false); - }; - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - void onSend(e as unknown as FormEvent); - } - }; - - // Empty state: no active thread - if (!activeThread) { - return ( -
- {isElectron &&
} -
-
-

Select a thread or create a new one to get started.

-
-
-
- ); - } - - return ( -
- {/* Top bar */} -
-
-

- {activeThread.title} -

-
-
- {/* Open in editor */} - {activeProject && ( -
- - {isEditorMenuOpen && ( -
- {EDITORS.map((editor) => ( - - ))} -
- )} -
- )} - {/* Diff toggle */} - -
-
- - {/* Error banner */} - {activeThread.error && ( -
- {activeThread.error} -
- )} - - {pendingApprovals.length > 0 && ( -
- {pendingApprovals.map((approval) => { - const isResponding = respondingRequestIds.includes( - approval.requestId, - ); - return ( -
-

- {approval.requestKind === "command" - ? "Command approval requested" - : "File-change approval requested"} -

- {approval.detail && ( -

- {approval.detail} -

- )} -
- - - - -
-
- ); - })} -
- )} - - {/* Messages */} -
- {activeThread.messages.length === 0 && !isWorking ? ( -
-

Send a message to start the conversation.

-
- ) : ( -
- {timelineEntries.map((timelineEntry, index) => { - if ( - timelineEntry.kind === "work" && - timelineEntries[index - 1]?.kind === "work" - ) { - return null; - } - - const showCompletionDivider = - timelineEntry.kind === "message" && - timelineEntry.message.role === "assistant" && - completionDividerBeforeEntryId === timelineEntry.id; - - if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; - } - - const groupId = timelineEntry.id; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = - groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every( - (entry) => entry.tone === "tool", - ); - const groupLabel = onlyToolEntries - ? groupedEntries.length === 1 - ? "Tool call" - : `Tool calls (${groupedEntries.length})` - : groupedEntries.length === 1 - ? "Work event" - : `Work log (${groupedEntries.length})`; - - return ( - -
-
-

- {groupLabel} -

- {hasOverflow && ( - - )} -
-
- {visibleEntries.map((workEntry) => ( -
- -

- {workEntry.detail ? ( - <> - {workEntry.label} - - {workEntry.detail} - - - ) : ( - workEntry.label - )} -

-
- ))} -
-
-
- ); - } - - if (timelineEntry.message.role === "user") { - return ( - -
-
-
-                          {timelineEntry.message.text}
-                        
-

- {formatTimestamp(timelineEntry.message.createdAt)} -

-
-
-
- ); - } - - return ( - - {showCompletionDivider && ( -
- - - {completionSummary - ? `Response • ${completionSummary}` - : "Response"} - - -
- )} -
- -

- {formatMessageMeta( - timelineEntry.message.createdAt, - timelineEntry.message.streaming - ? formatElapsed( - timelineEntry.message.createdAt, - nowIso, - ) - : formatElapsed( - timelineEntry.message.createdAt, - assistantCompletionByItemId.get( - timelineEntry.message.id, - ), - ), - )} -

-
-
- ); - })} - {isWorking && ( -
- -
- - - - - -
-
- )} -
-
- )} -
- - {/* Input bar */} -
-
-
- {/* Textarea area */} -
-