diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index 62884bcde3..482f260591 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 { spawn, execSync } from "node:child_process"; +import { execSync, spawn } 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 new file mode 100644 index 0000000000..8d7bf441ac --- /dev/null +++ b/apps/renderer/components.json @@ -0,0 +1,25 @@ +{ + "$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 7084d1a08b..c2c8793a7c 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 b91d3d1dae..8ab450ea8c 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -12,12 +12,17 @@ }, "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 252e1cd445..a525c7caeb 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 1d51a76851..5104ddced5 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()); + if (tone === "error") return "text-destructive/50"; + if (tone === "tool") return "text-muted-foreground"; + if (tone === "thinking") return "text-muted-foreground/70"; + return "text-muted-foreground/60"; } export default function ChatView() { @@ -123,46 +65,30 @@ 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 ?? [], undefined), - [activeThread?.events], - ); - const pendingApprovals = useMemo( - () => derivePendingApprovals(activeThread?.events ?? []), - [activeThread?.events], + () => deriveWorkLogEntries(activeThread?.events ?? [], activeTurnId), + [activeThread?.events, activeTurnId], ); const assistantCompletionByItemId = useMemo(() => { const map = new Map(); - const ordered = [...(activeThread?.events ?? [])].toReversed(); + const ordered = [...(activeThread?.events ?? [])].reverse(); for (const event of ordered) { if (event.method !== "item/completed") continue; if (!event.itemId) continue; @@ -171,113 +97,27 @@ export default function ChatView() { return map; }, [activeThread?.events]); const timelineEntries = useMemo( - () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), - [activeThread?.messages, workLogEntries], + () => + deriveTimelineEntries( + activeThread?.messages ?? [], + isWorking ? workLogEntries : [], + ), + [activeThread?.messages, isWorking, 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(() => { @@ -288,63 +128,6 @@ 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") { @@ -357,8 +140,6 @@ export default function ChatView() { provider: "codex", cwd: activeProject.cwd || undefined, model: selectedModel || undefined, - approvalPolicy: runtimeSessionConfig.approvalPolicy, - sandboxMode: runtimeSessionConfig.sandboxMode, }); dispatch({ type: "UPDATE_SESSION", @@ -386,7 +167,8 @@ 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, @@ -437,37 +219,6 @@ 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({ @@ -475,7 +226,6 @@ export default function ChatView() { threadId: activeThread.id, model: resolveModelSlug(model), }); - setIsModelMenuOpen(false); }; const onKeyDown = (e: KeyboardEvent) => { @@ -488,11 +238,13 @@ 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. +

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

+

{activeThread.title}

+ {activeProject && ( + + {activeProject.name} + + )}
- {/* Open in editor */} - {activeProject && ( -
- - {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, index) => ( - - {timelineEntry.kind === "message" && - timelineEntry.message.role === "assistant" && - (completionDividerBeforeEntryId === timelineEntry.id || - timelineEntries[index - 1]?.kind === "work") && ( -
- - - {completionSummary - ? `Response • ${completionSummary}` - : "Response"} - - + {timelineEntries.map((timelineEntry) => + 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.kind === "work" ? ( -
- -

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

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

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

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

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

-
- ) : ( -
- - {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, - ), - ), - )} -

-
- )} - - ))} + )} +
+ ), + )} {isWorking && ( -
- +
- - - + + +
@@ -757,271 +428,101 @@ export default function ChatView() { {/* Input bar */}
-
- {/* Textarea area */} -
-