diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 482f260591ef7064e6a71c2dd572de39947f9b37..62884bcde3eed6134d9edfdb845671a2028b5a86 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -13,7 +13,7 @@ * the first render). For production mode (no React DevTools message), we * instead check that no fatal errors appeared. */ -import { execSync, spawn } from "node:child_process"; +import { spawn, execSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -75,7 +75,7 @@ child.on("exit", () => { for (const f of failures) { console.error(` • ${f}`); } - console.error(`\nFull output:\n${output}`); + console.error("\nFull output:\n" + output); process.exit(1); } diff --git a/apps/renderer/components.json b/apps/renderer/components.json deleted file mode 100644 index 8d7bf441ac8f58d7271472634227d4502d8fe851..0000000000000000000000000000000000000000 --- a/apps/renderer/components.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/index.css", - "baseColor": "zinc", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "rtl": false, - "aliases": { - "components": "#/components", - "utils": "#/lib/utils", - "ui": "#/components/ui", - "lib": "#/lib", - "hooks": "#/hooks" - }, - "registries": { - "@coss": "https://coss.com/ui/r/{name}.json" - } -} diff --git a/apps/renderer/index.html b/apps/renderer/index.html index c2c8793a7c00635add4a75de3659a3449480ce04..7084d1a08b198f7188a00f59ff3d6328c62f9d3f 100644 --- a/apps/renderer/index.html +++ b/apps/renderer/index.html @@ -5,7 +5,7 @@ Electron Todo - +
diff --git a/apps/renderer/package.json b/apps/renderer/package.json index 8ab450ea8cc8fb5d340389c6a2ded4fc450e98b4..b91d3d1dae74e61b449a2bf191f3475f41342506 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -12,17 +12,12 @@ }, "dependencies": { "@acme/contracts": "workspace:*", - "@base-ui/react": "^1.1.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", "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", - "tailwind-merge": "^3.4.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index a525c7caebec9c3705211e7722daca99c8cecbd1..252e1cd4455782b1abe2754a3a823eb4e26a8ce7 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -31,10 +31,10 @@ function Layout() { if (!api) { return ( -
+
-

+

Native bridge unavailable. Launch through Electron.

@@ -43,7 +43,7 @@ function Layout() { } return ( -
+
diff --git a/apps/renderer/src/components/ChatMarkdown.tsx b/apps/renderer/src/components/ChatMarkdown.tsx index 5104ddced5e66cfd9c8d77207f41406f36bdf69d..1d51a76851897ea6d488f956681013d31d2928bd 100644 --- a/apps/renderer/src/components/ChatMarkdown.tsx +++ b/apps/renderer/src/components/ChatMarkdown.tsx @@ -15,7 +15,7 @@ const markdownComponents: Components = { export default function ChatMarkdown({ text }: ChatMarkdownProps) { return ( -
+
| 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].reverse(); + + 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() { @@ -65,30 +123,46 @@ export default function ChatView() { 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 [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 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 activeTurnId = activeThread?.session?.activeTurnId; const nowIso = new Date(nowTick).toISOString(); const modelOptions = MODEL_OPTIONS; const workLogEntries = useMemo( - () => deriveWorkLogEntries(activeThread?.events ?? [], activeTurnId), - [activeThread?.events, activeTurnId], + () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), + [activeThread?.events], + ); + const pendingApprovals = useMemo( + () => derivePendingApprovals(activeThread?.events ?? []), + [activeThread?.events], ); const assistantCompletionByItemId = useMemo(() => { const map = new Map(); - const ordered = [...(activeThread?.events ?? [])].reverse(); + const ordered = [...(activeThread?.events ?? [])].toReversed(); for (const event of ordered) { if (event.method !== "item/completed") continue; if (!event.itemId) continue; @@ -97,27 +171,113 @@ export default function ChatView() { return map; }, [activeThread?.events]); const timelineEntries = useMemo( - () => - deriveTimelineEntries( - activeThread?.messages ?? [], - isWorking ? workLogEntries : [], - ), - [activeThread?.messages, isWorking, workLogEntries], + () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), + [activeThread?.messages, workLogEntries], ); + const completionSummary = useMemo(() => { + if (!activeThread?.latestTurnStartedAt) return null; + if (!activeThread.latestTurnCompletedAt) return null; + if (workLogEntries.length === 0) 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, + workLogEntries.length, + ]); + const completionDividerBeforeEntryId = useMemo(() => { + if (!activeThread?.latestTurnStartedAt) return null; + if (!activeThread.latestTurnCompletedAt) return null; + if (workLogEntries.length === 0) return null; + + const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); + if (Number.isNaN(turnStartedAt)) return null; + + const entry = timelineEntries.find((timelineEntry) => { + if (timelineEntry.kind !== "message") return false; + if (timelineEntry.message.role !== "assistant") return false; + const messageAt = Date.parse(timelineEntry.message.createdAt); + return !Number.isNaN(messageAt) && messageAt >= turnStartedAt; + }); + return entry?.id ?? null; + }, [ + activeThread?.latestTurnStartedAt, + activeThread?.latestTurnCompletedAt, + timelineEntries, + workLogEntries.length, + ]); + 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; - // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messageCount]); - // biome-ignore lint/correctness/useExhaustiveDependencies: auto-scroll while active work-log events stream in useEffect(() => { if (phase !== "running") return; messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [phase, workLogCount]); + // 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(() => { @@ -128,6 +288,63 @@ export default function ChatView() { }; }, [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") { @@ -140,6 +357,8 @@ export default function ChatView() { provider: "codex", cwd: activeProject.cwd || undefined, model: selectedModel || undefined, + approvalPolicy: runtimeSessionConfig.approvalPolicy, + sandboxMode: runtimeSessionConfig.sandboxMode, }); dispatch({ type: "UPDATE_SESSION", @@ -167,8 +386,7 @@ export default function ChatView() { // Auto-title from first message if (activeThread.messages.length === 0) { - const title = - trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed; + const title = trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed; dispatch({ type: "SET_THREAD_TITLE", threadId: activeThread.id, @@ -219,6 +437,37 @@ export default function ChatView() { }); }; + 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({ @@ -226,6 +475,7 @@ export default function ChatView() { threadId: activeThread.id, model: resolveModelSlug(model), }); + setIsModelMenuOpen(false); }; const onKeyDown = (e: KeyboardEvent) => { @@ -238,13 +488,11 @@ export default function ChatView() { // Empty state: no active thread if (!activeThread) { return ( -
+
-

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

+

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

@@ -252,61 +500,55 @@ export default function ChatView() { } return ( -
+
{/* Top bar */} -
+
-

+

{activeThread.title}

+
+
+ {/* Open in editor */} {activeProject && ( - - {activeProject.name} - +
+ + {isEditorMenuOpen && ( +
+ {EDITORS.map((editor) => ( + + ))} +
+ )} +
)} -
-
- {/* Status indicator */} -
- - {(phase === "running" || phase === "connecting") && ( - - )} - - - {statusLabel(phase)} -
{/* Diff toggle */} + + + +
+
+ ); + })} +
+ )} + {/* Messages */}
{activeThread.messages.length === 0 && !isWorking ? (
-

- Send a message to start the conversation. -

+

Send a message to start the conversation.

) : (
- {timelineEntries.map((timelineEntry) => - timelineEntry.kind === "work" ? ( -
-

- {timelineEntry.entry.detail ? ( - <> - {timelineEntry.entry.label} - - {timelineEntry.entry.detail} + {timelineEntries.map((timelineEntry, index) => ( + + {timelineEntry.kind === "message" && + timelineEntry.message.role === "assistant" && + (completionDividerBeforeEntryId === timelineEntry.id || + timelineEntries[index - 1]?.kind === "work") && ( +

+ + + {completionSummary + ? `Response • ${completionSummary}` + : "Response"} + + +
+ )} + {timelineEntry.kind === "work" ? ( +
+ +

+ {timelineEntry.entry.detail ? ( + <> + {timelineEntry.entry.label} + + {timelineEntry.entry.detail} + + + ) : ( + timelineEntry.entry.label + )} +

+
+ ) : timelineEntry.message.role === "user" ? ( +
+
+
+                        {timelineEntry.message.text}
+                      
+

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

+
+
+ ) : ( +
+ + {timelineEntry.message.streaming && ( +
+ + + + + + + Thinking - - ) : ( - timelineEntry.entry.label - )} -

-
- ) : ( -
- {timelineEntry.message.role === "user" ? ( -
-
-
-                          {timelineEntry.message.text}
-                        
-

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

-
- ) : ( -
- - {timelineEntry.message.streaming && ( -
- - - - - - - Thinking - -
- )} -

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

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

-
- )} -
- ), - )} + ), + )} +

+
+ )} + + ))} {isWorking && ( -
+
+
- - - + + +
@@ -428,101 +757,271 @@ export default function ChatView() { {/* Input bar */}
- - setPrompt(e.target.value)} - onKeyDown={onKeyDown} - placeholder={ - phase === "disconnected" - ? "Ask for follow-up changes" - : "Ask anything..." - } - disabled={isSending || isConnecting} - rows={2} - className="font-sans!" - /> - - {/* Model picker */} - +
+ {/* Textarea area */} +
+