Skip to content

feat(frontend): rebuild Electron desktop UI as a React + Vite renderer#156

Closed
ashish921998 wants to merge 22 commits into
mainfrom
feat/electron-ui-redesign
Closed

feat(frontend): rebuild Electron desktop UI as a React + Vite renderer#156
ashish921998 wants to merge 22 commits into
mainfrom
feat/electron-ui-redesign

Conversation

@ashish921998

@ashish921998 ashish921998 commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

What

Rebuilds the Electron desktop frontend as a React + Vite + TypeScript renderer and implements the full intended stack boilerplate (see the layer table below). Started as a restyle of the single-file main.ts, pivoted to a real renderer architecture, then filled in every stack layer.

Stack status

Layer Choice Status
Shell Electron + child_process daemon supervision
UI React 19 + TypeScript
Build vite-plugin-electron + electron-builder ✅ (auto-update scaffolded, needs signing)
Data TanStack Query + EventTransport ✅ EventTransport subscribes to the /api/v1/events CDC SSE + daemon IPC
API types openapi-typescript + openapi-fetch
Styling Tailwind + shadcn/ui ✅ Badge/Tabs/Button/Input/Tooltip all in use
Layout react-resizable-panels ✅ resizable sidebar split, sizes persisted to Zustand
Terminal @xterm/xterm ✅ per-session PTY over the /mux WebSocket + WebGL (canvas fallback)
UI state Zustand ✅ selection, active pane, theme, layout sizes
Testing Vitest + RTL · Playwright ✅ both

Highlights

  • Per-session terminal. New terminal-mux.ts WebSocket client speaks the daemon /mux protocol (open/data/resize/close, base64 frames) matched to backend/internal/terminal/protocol.go; TerminalPane streams real PTY output, forwards keystrokes/resize, and renders via the WebGL addon. Framing is unit-tested.
  • Live data. EventTransport invalidates the workspace query on CDC SSE events and daemon status (was previously dead-wired to an unused key).
  • Resizable layout with sizes persisted to the Zustand store; shadcn Tabs (Terminal/Details), status Badge, Button, and free-text branch Input now used.
  • Auto-update scaffold (electron-updater + publish config + release CI + signing checklist in frontend/docs/desktop-release.md).
  • Playwright e2e restored against the current UI.

Verification

  • tsc --noEmit ✅ · Vitest 12/12 ✅ (incl. mux framing) · Playwright discovery ✅.
  • An adversarial multi-agent review ran on the implementation; all 6 confirmed findings fixed (resize/open ordering, terminal forceMount across tabs, rAF cancel, ws-url cleanup, autoUpdater rejection handling, Playwright Electron-launch suppression).

Needs interactive / live verification (wired + type-safe, not runtime-proven here)

  • Terminal PTY requires a running daemon with the /mux terminal manager mounted; the WebSocket client + framing are tested but live PTY behavior is unproven in CI.
  • Resizable layout + visual polish need a real Electron window.
  • Auto-update is inert until an Apple signing cert + notarization + CI secrets are added (see the release doc).

🤖 Generated with Claude Code

Rework the placeholder Electron shell into a clean, minimal Codex-style
desktop UI. The single-file renderer (src/main.ts) builds the whole
HTML/CSS/JS, so this rewrites only the three render functions; the data
model and Electron lifecycle are untouched.

- Replace every placeholder glyph (folder, sidebar toggle, search, the
  literal "S" settings icon, stop, close, chevron) with real inlined
  Lucide SVGs via a small icon registry + icon() helper at a uniform
  1.75 stroke. No more programmer-art glyphs.
- Lock a cool-neutral zinc palette, semantic green/amber/red status, a
  near-black primary button, and one documented radius scale.
- Add per-worker status dots; long titles now ellipsize cleanly.
- Drop dead cruft (invisible window-dot buttons) and dev-jargon copy
  leaking into the terminal placeholder text.
- Drop Inter from the font stack in favor of the native SF Pro system.

Fixes two layout bugs found while verifying the running app:
- Sidebar worker labels were clipped (CSS grid min-width:auto); list
  tracks now use minmax(0, 1fr) so titles truncate and trailing status
  labels stay visible.
- Settings modal overflowed its fixed-width <dialog>; the dialog now
  sizes to its content and width lives on each panel.

Also exclude src/landing from the frontend tsconfig so the Electron
shell typecheck/build do not pull in the separate Next.js landing app.

Verified in the running app across the orchestrator, worker, new-task,
and settings surfaces; npm run typecheck and npm run build pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR rebuilds the Electron desktop frontend as a full React + Vite + TypeScript renderer, replacing a single-file main.ts with a layered architecture covering data fetching (TanStack Query + SSE event transport), terminal multiplexing (WebSocket mux + xterm), UI state (Zustand), routing (TanStack Router), and a custom CORS middleware on the backend daemon.

  • Electron main process now supervises the daemon subprocess with dual port-discovery (log scan + running.json polling), serves the packaged renderer under a custom app://renderer scheme, and exposes IPC via a contextBridge preload.
  • Terminal layer (terminal-mux.ts + useTerminalSession) implements the mux framing protocol end-to-end with frame queuing, exponential-backoff reconnect, and daemonReady-triggered reattachment; all tested with unit and Playwright suites.
  • Live data pipeline (event-transport.ts + useDaemonStatus) connects SSE CDC events and daemon IPC status changes to debounced workspace-query invalidation, with correct port-rebinding on daemon restarts.

Confidence Score: 4/5

Safe to merge with one form-logic fix: the SpawnWorkerModal worker-name field gates submission via a regex validator but the value is never forwarded to the API.

The core Electron process, terminal mux, event transport, and API rebasing are all well-structured and correctly guarded. The one concrete defect is in SpawnWorkerModal where name validation silently blocks form submission for a field whose value is discarded before the API call. All previously-reviewed issues have been addressed.

frontend/src/renderer/components/SpawnWorkerModal.tsx — the worker name validation gate should be reconciled with whether the field is actually submitted.

Important Files Changed

Filename Overview
frontend/src/main.ts Rebuilt Electron main process: custom app:// protocol, daemon supervision with port discovery (log scan + run-file polling), IPC handlers, and auto-update scaffold. Process-group kill and stale-listener guard are correctly implemented.
frontend/src/renderer/lib/terminal-mux.ts WebSocket mux client with frame queuing, connection-state dedup, and ping keepalive. Frame helpers are pure and unit-tested; dispose() cleans all maps and closes the socket.
frontend/src/renderer/hooks/useTerminalSession.ts Terminal attachment lifecycle hook with exponential backoff, daemonReady-triggered reconnect, and stale-closure avoidance via connectRef. One redundant resize frame is sent immediately after open.
frontend/src/renderer/components/TerminalPane.tsx XtermTerminal component with WebGL to CanvasAddon fallback, ResizeObserver-driven fit, and RAF-coordinated attach/detach lifecycle. Static preview renders correctly when Electron IPC is unavailable.
frontend/src/renderer/components/SpawnWorkerModal.tsx New worker spawn modal. The name field is validated and gates submission via canSubmit, but is never forwarded to onCreateTask — users who enter a non-matching name are silently blocked.
backend/internal/httpd/cors.go New CORS middleware enforcing explicit origin allowlist plus loopback passthrough. Preflight handler reflects arbitrary Access-Control-Request-Headers verbatim instead of a fixed allowlist.
frontend/src/renderer/lib/event-transport.ts SSE + daemon IPC event transport with debounced workspace invalidation, CLOSED-state retry, and port-change rebinding. Lifecycle correctly cleans up on disconnect.
frontend/src/renderer/lib/api-client.ts openapi-fetch client with runtime base-URL rebasing and per-request body buffering to work around Electron's missing duplex support. Early-exit path avoids unnecessary rebasing for matching URLs.
frontend/src/renderer/App.tsx Root component wiring daemon status, workspace query, optimistic updates via useQueryClient(), keyboard shortcuts, and theme propagation. createProject and createTask correctly write to the query cache.

Sequence Diagram

sequenceDiagram
    participant M as Electron Main
    participant P as Preload (contextBridge)
    participant R as Renderer (React)
    participant T as EventTransport
    participant MX as TerminalMux (WS)
    participant D as Daemon (loopback)

    M->>M: spawn daemon (AO_DAEMON_COMMAND)
    M->>M: scan stdout/stderr for listen port
    M-->>R: "daemon:status { state:ready, port }"
    R->>P: daemon.getStatus() via IPC
    P-->>R: DaemonStatus
    R->>T: createEventTransport.connect()
    T->>D: EventSource /api/v1/events (SSE)
    D-->>T: CDC events (session_created, etc.)
    T->>R: invalidateQueries([workspaces])
    R->>D: GET /api/v1/projects + /api/v1/sessions
    D-->>R: workspace data

    R->>MX: createTerminalMux(ws://127.0.0.1:PORT/mux)
    MX->>D: "open{id, cols, rows}"
    D-->>MX: "opened{id}"
    MX-->>R: onOpened - state:attached
    D-->>MX: "data{id, base64}"
    MX-->>R: onData - xterm.write(bytes)
Loading

Reviews (7): Last reviewed commit: "Merge origin/main into feat/electron-ui-..." | Re-trigger Greptile

Comment thread frontend/src/main.ts Outdated
Comment thread frontend/src/main.ts Outdated
Comment thread frontend/src/main.ts Outdated
Comment thread frontend/src/renderer/lib/api-client.ts Outdated
ashish921998 and others added 3 commits June 9, 2026 17:26
…sign

- api-client: drop duplicate /api/v1 from baseUrl; openapi-fetch concatenates
  baseUrl + the schema path (which already includes /api/v1), so every real
  daemon call was resolving to /api/v1/api/v1/* and 404ing.
- main: enable the renderer sandbox and add setWindowOpenHandler (deny +
  shell.openExternal) plus a will-navigate guard, before daemon stdout is wired.
- useWorkspaceQuery: only fall back to mock workspaces under import.meta.env.DEV;
  in production surface the real empty/error state instead of dev fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the e2e spec, playwright config, the test:e2e script, the
@playwright/test and playwright devDependencies, and their tsconfig/vitest
references. Vitest unit tests remain the frontend test surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ashish921998 ashish921998 changed the title feat(frontend): redesign Electron app UI with a clean Lucide-icon system feat(frontend): rebuild Electron desktop UI as a React + Vite renderer Jun 9, 2026
@ashish921998

Copy link
Copy Markdown
Collaborator Author

Greptile triage (all four resolved):

  • api-client.ts:8 — double /api/v1 (P1): Confirmed and fixed in 43183ac. baseUrl is now apiBaseUrl (host only), since the schema paths already carry /api/v1. Matches the suggested fix exactly.
  • main.ts:838 — unquittable app on before-quit rejection (P1): No longer applicable. That async confirmStopSessions() / dialog.showMessageBox path lived in the old inline-HTML main.ts (e2e86bd), which was replaced by the React renderer. The current before-quit (main.ts:171) is a synchronous daemonProcess.kill() with no preventDefault.
  • main.ts:823 — dead app:confirm-stop-sessions IPC (P2): No longer applicable. That handler was removed in the React pivot, and a preload.ts bridge now exists (the "no preload configured" premise is outdated).
  • main.ts:726String(Date.now()).slice(-4) ID collisions (P2): No longer applicable. showWorker/renderWorkspaceList were removed; session ids now come from the daemon API, with a base36 timestamp only as an offline fallback.

main.ts is now 181 lines (was ~900); the three stale comments reviewed the pre-pivot file.

Comment thread frontend/src/renderer/App.tsx Outdated
ashish921998 and others added 8 commits June 9, 2026 17:37
App took queryClient as an optional prop and guarded updateWorkspaces behind
queryClient?.setQueryData, but router.tsx renders <App> on every route without
passing it, so the prop was always undefined and createProject/createTask never
updated the sidebar in the real app (the dummy-session fallback was permanently
invisible). Use useQueryClient() from the surrounding QueryClientProvider instead.
Tests now exercise the same context path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… deps

Also adds the electron-builder build.publish (GitHub) config and the test:e2e
script used by the auto-update and e2e layers below.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New terminal-mux client speaks the daemon's /mux protocol (open/data/resize/close,
base64 frames, ch "terminal") matched to backend/internal/terminal/protocol.go.
TerminalPane attaches the selected session's PTY, forwards keystrokes/resize, and
renders with the WebGL addon (canvas fallback). Opens before resizing since the
backend drops a resize for an unregistered pane. Framing is unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EventTransport now subscribes to GET /api/v1/events (named CDC events from
backend/internal/cdc) and the daemon IPC status, invalidating the ["workspaces"]
query (debounced) instead of a dead ["daemon"] key. EventSource is guarded for
jsdom/preview. Test mock updated for the new apiBaseUrl export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
App uses react-resizable-panels (Group/Panel/Separator) for a draggable sidebar
split, with sizes persisted to the Zustand store. The main pane gains shadcn Tabs
(Terminal/Details, terminal forceMounted so its PTY survives tab switches) and a
status Badge; the composer uses Button and a free-text branch Input. All five
shadcn ui components are now in use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires autoUpdater.checkForUpdatesAndNotify() (packaged builds only) and a
desktop-release workflow that publishes installers + the update feed. Going live
needs Apple signing + notarization + CI secrets, documented in
frontend/docs/desktop-release.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Config + workbench spec assert the current React UI (mock-data driven), with
ELECTRON_STARTUP_PREVENT so the dev server doesn't launch Electron during e2e.
Re-adds the tsconfig include and vitest e2e exclude.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Running the Electron app surfaced a WebglAddon dispose crash (reading '_isDisposed'
on a half-initialised addon) that threw <XtermTerminal> into the router error
boundary. Probe for a real WebGL2 context before loading the addon, and guard
terminal.dispose() so a renderer-addon teardown error can't crash React.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread frontend/src/main.ts Outdated
ashish921998 and others added 2 commits June 9, 2026 20:49
…or handling

Computer-use QA of the running Electron app surfaced and fixed:
- collapsed sidebar no longer drops the main-header tabs/status badge
  (WorkbenchMain now fills width in both the resizable and rail layouts)
- task composer closes on Escape
- "needs input" sessions render an amber badge (was muted gray); failed uses outline
- terminal shows an "Attaching to <session>…" state instead of a blank void
- session-row title truncates cleanly to the left of the hover actions (in-flow)

Also folds in concurrent refinements to the same files: createTask surfaces API
errors instead of fabricating a dummy session, the default daemon port is 3001,
the default selection starts empty, and the terminal theme updates in place
without rebuilding the pane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The daemon exit/error/spawn listeners closed over the module-level daemonProcess.
If stopDaemon nulled it and startDaemon spawned a replacement before the old
process's exit fired, the stale handler would null the new reference, leaving an
orphaned untracked daemon (stop() then no-ops, starts accumulate). Capture the
spawned handle locally and guard every listener with daemonProcess === child.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,51 @@
import { createHashHistory, createRootRoute, createRoute, createRouter, Outlet } from "@tanstack/react-router";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matches the locked design (docs/designs/desktop-app-design.html §4.2): TanStack Router is exactly the chosen router, paired with the TanStack Query you're already using. Flagging explicitly because this is a real @tanstack/react-router setup (hash history + typed Register), not a hand-rolled router — so there's no routing divergence to reconcile here. The RootLayoutApp param-wrapper pattern is clean.

@@ -0,0 +1,84 @@
export type SessionStatus = "running" | "needs_input" | "stopped" | "failed";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Steering, not a nitpick — status spectrum. This 4-value SessionStatus (running|needs_input|stopped|failed) is a third status vocabulary: it's neither the daemon's 12-value derived set (docs/architecture.md:40-49 — terminated/merged/needs_input/ci_failed/draft/changes_requested/mergeable/approved/review_pending/pr_open/working/idle) nor the legacy StatusTone spectrum. The agreed plan (design doc §7 status-spectrum audit) keeps the Go set authoritative and maps Go→StatusTone via a client-side shim. useWorkspaceQuery.ts:sessionStatus() already hand-collapses Go statuses, so every PR/review state (ci_failed, changes_requested, approved, …) is silently lost here. Recommend aligning to the Go set + a shim rather than a new 4-value enum.

connect: () => () => void;
};

const EVENTS_URL = `${apiBaseUrl.replace(/\/+$/, "")}/api/v1/events`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strong, and a borrow-back for our plan. I verified this against the backend: /api/v1/events is a real SSE endpoint (backend/internal/httpd/events.go:74-80text/event-stream, emits id:/event:/data: and honors Last-Event-ID), and all 8 CDC_EVENT_TYPES here match backend/internal/cdc/event.go:23-30 exactly. The named-event + auto-reconnect approach is correctly matched to the contract.

This is cleaner than the 'CDC over the /mux sessions channel' route our design doc assumed (§7 mux audit) — separate transports, free EventSource resume. We should adopt this SSE pattern in the doc and retire the sessions-channel-for-CDC idea.

One caveat: events.go:40-43 returns 501 when the controller deps aren't wired — worth confirming Source/Live are mounted on this PR's base main (they're stubbed in the worktree I reviewed against).

Comment thread frontend/src/renderer/lib/api-client.ts Outdated
import createClient from "openapi-fetch";
import type { paths } from "../../api/schema";

export const apiBaseUrl = import.meta.env.VITE_AO_API_BASE_URL ?? "http://127.0.0.1:3001";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Three coupled gaps vs the connection model (design doc §4.5/§5), all latent today because the daemon is loopback-no-auth:

  1. Port discoveryapiBaseUrl hardcodes :3001, but the daemon binds whatever port it's given (and may use 0→OS-assigned). The supervisor should read the actual port from running.json (backend/internal/runfile/runfile.go:20-26) and hand it to the renderer rather than assume the default.
  2. Token — the remote model (§5) needs a bearer token; on the WS it must ride the Sec-WebSocket-Protocol subprotocol since browsers can't set headers on a WS handshake.
  3. CORS — packaged renderer origin is file:// but it fetches http://127.0.0.1:PORT directly, and the daemon has no CORS (confirmed: no CORS middleware in backend/internal/httpd; README.md:94), so those requests will be blocked.

Your own frontend-shell-decision.html landmines flag (1) and (3) too. None block this PR — they're the next real work, and mostly daemon-side.

Comment thread frontend-shell-decision.html Outdated
@@ -0,0 +1,476 @@
<!doctype html>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📄 Doc reconciliation — for the team, not a change to this file. This decision record lands on Electron, same as our design doc (docs/designs/desktop-app-design.html) — good, we agree on the conclusion. But the bases differ in ways that matter:

  • It drops Linux ('macOS + Windows'); our doc commits to Linux binaries as a hard requirement → 'is Linux a target?' is now an open conflict.
  • Its deciding axis is language count, and it never mentions the agent-controllable in-app browser (multi-tab, CDP-driven) that our doc treats as the reason Electron wins and as a locked Phase-3 feature. If that requirement still holds, Electron is locked for a stronger reason and Linux likely has to stay.
  • Two decision docs at two paths (this one at repo root, ours under docs/designs/) = no single source of truth.

The '~0% renderer built' landmine in this doc is also now superseded by this very PR. Suggest the orchestrator merges the two into one record.

@AgentWrapper AgentWrapper left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Architectural review — alignment with the locked frontend design doc

Reviewing for architectural alignment with docs/designs/desktop-app-design.html, not generic correctness (Greptile's two correctness bugs stand — see the bottom). Backend claims verified against the daemon source; inline notes are on the specific lines. Net: comment-only — the stack is right and cleanly built, but the PR answers two questions the team had already answered differently, and those need a ruling rather than a code change forced on the author.

✅ Strong alignment (validates the design doc)

Electron · React 19 + Vite + TS · TanStack Query and Router · Tailwind · xterm.js + WebGL (probe → canvas fallback) · electron-updater · Zustand — all exactly the locked stack, implemented cleanly. Electron security posture is right (contextIsolation/sandbox/nodeIntegration:false, contextBridge with unsubscribe, navigation hardening). The /mux client (terminal-mux.ts) matches protocol.go and is framing-tested.

Notably good: the SSE event wiring is precisely matched to the real backend — /api/v1/events is a genuine SSE stream (events.go:74-80) and the 8 hardcoded CDC event names match cdc/event.go:23-30 exactly. Real backend research, not guesswork.

⤴️ Borrow back into our plan

  1. Promote OpenAPI codegen from phase-2 to phase-0openapi-typescript + openapi-fetch off openapi.yaml is strictly better than hand-porting types for bootstrap.
  2. Adopt the SSE EventTransport split (terminal=/mux, CDC=SSE, refetch-on-event) and retire the doc's "CDC over the mux sessions channel" idea — theirs is cleaner.
  3. react-resizable-panels, the WebGL-probe terminal renderer, and the electron-updater release CI + signing checklist are all worth keeping.

⚠️ Divergences (calls in the report)

  • D1 — this is a stack scaffold, not the 1:1 legacy port (team decision). New mock-data components, a new light-default design system (Sidebar even hardcodes light hex, ignoring the dark theme), and a new 4-value status enum — vs the locked dark-only Mission Control + 12-status-via-shim. Legitimate as a scaffold; doesn't fulfil the 1:1-port mandate.
  • D2 — single npm app, not the packages/core+packages/runtime pnpm workspace (defer). Seam is clean, mobile is OPEN, so extraction later is cheap — but track it.
  • D3 — daemon supervisor is a stub (we align): spawns AO_DAEMON_COMMAND, no running.json discovery, no /readyz poll, no token, no /shutdown. Your own decision doc specifies all of these.
  • D4 — no CORS/token for direct renderer→daemon (daemon-side, latent): file://127.0.0.1 is cross-origin and the daemon has no CORS/token. Next real work.
  • D5 — second decision doc (frontend-shell-decision.html) lands on Electron too 👍 but drops Linux (our doc requires it) and never mentions the agent-controllable in-app browser that our doc treats as the deciding factor + a Phase-3 feature. Two docs, two paths → reconcile to one.

Mux parity

Our audit found the legacy mux can't port 1:1; this PR sidesteps it well — sessions channel unused, CDC via SSE. Two residual gaps: no terminal reconnect (the backend supports reconnect-resubscribe; a blip kills the pane) and one-WS-per-pane forgoes multiplexing (fine for the single-pane tab UI now).

Deferred to Greptile (correctness, not this lens)

useWorkspaceQuery maps path to the wrong field (every project shows its UUID); createTask swallows the API error and closes the composer silently; global.d.ts types window.ao as non-optional. Worth fixing before merge.

Full write-up with file:line and per-divergence resolutions: docs/reviews/pr-156-review.md (in the ao-15 worktree). The two team decisions (D1, D5) are headed to the orchestrator.

ashish921998 and others added 2 commits June 9, 2026 22:27
Adopt the emdash design system for the Electron renderer and document it as the
source of truth in DESIGN.md (CLAUDE.md routes to it before any UI work).

- Theme: system font + flat near-black neutral ramp, refined-blue accent, light/dark
- Orchestrator-led shell: pinned orchestrator (orchestrator-first), Projects with
  name-only worker rows, and a per-project "+" that opens the spawn-worker modal
  scoped to that project
- Worker workspace: agent terminal as the conversation + Git changes/commit rail;
  contextual topbar (PR/CI pill + Changes/Files/Terminal toggles + session menu)
- Spawn-worker modal mirroring emdash's Create Task, mapped to `ao spawn`
- Session status as a single glyph (spinner while working / PR icon / dot), no text badges

Verified: tsc --noEmit clean, vitest 12/12 (playwright e2e not run here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ew hardening

Connects the emdash redesign to the running backend instead of mock data:
- session terminalHandleId + project path plumbed through the Go DTOs/OpenAPI
  and the generated frontend schema, so the renderer can attach PTYs and show
  real project paths
- dynamic API base URL keyed off the daemon's reported port, with the CDC SSE
  event transport reconnecting when the port changes
- drop the renderer mock-data module

Pre-landing review fixes (cheap/mechanical, per /ship triage):
- main.ts: spawn the daemon detached and kill its whole process group so the
  signal reaches the real daemon behind the /bin/sh wrapper, not just the shell
- TerminalPane: drop the unused session.provider effect dep that needlessly
  recreated the terminal + mux socket
- remove dead code: ui-store setView/setSidebarOpen, api-client apiBaseUrl
  export, terminal-mux ServerFrame.session field
- add unit coverage: workspace status helpers, event-transport reconnect logic,
  api-client empty-base path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ashish921998 and others added 4 commits June 10, 2026 01:06
Format-only pre-commit: Prettier on staged frontend files, gofmt on
staged Go files. Prettier config tuned to the existing frontend style
(2-space, double-quote, semicolons, trailing commas, printWidth 120).
Generated artifacts (schema.ts, openapi.yaml) are ignored so the
api-drift CI job stays authoritative.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One-time formatting pass so future per-file diffs stay clean under the
new pre-commit hook. Applies the committed .prettierrc to tracked
frontend/src files that had no in-flight changes; files with active WIP
are left to normalize when next committed. Generated artifacts
(schema.ts) remain ignored. Formatting-only — no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rigin, CORS)

The desktop build's network path was never exercised — dev worked only
because Vite proxies everything same-origin. Three independent breakages:

- runtimeFetch rebuilt requests via `new Request(target, input)`, which
  needs the source request's `duplex` getter; Electron's Chromium lacks
  it, so every request with a body threw. Copy fields explicitly and
  buffer the small JSON body instead.
- The renderer was loaded from file://, whose opaque "null" origin can
  never be trusted by the daemon (sandboxed iframes on any website share
  it) and which also breaks absolute /assets URLs. Serve the packaged
  renderer from app://renderer, an origin only this app can present.
- The daemon had no CORS support, so even a trusted origin could not
  read responses. Add an origin allowlist (default: app://renderer, plus
  loopback-served content, which can already reach the daemon directly).

Per Codex review: requests bearing a non-allowlisted Origin are rejected
with 403 before any handler runs — omitting CORS headers alone would hide
the response but still execute "simple" no-cors cross-origin POSTs on a
no-auth daemon that can spawn agent sessions. This closes a pre-existing
drive-by CSRF hole, not just a regression.

AO_ALLOWED_ORIGINS overrides the allowlist; "null" and "*" are config
errors. Verified live: hostile-origin POST 403s, app://renderer and
loopback dev origins are granted, no-Origin CLI traffic is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

React Doctor found no issues. 🎉

Reviewed by React Doctor for commit c01cf7a.

@yyovil yyovil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review note: please split formatter setup from feature changes

The PR bundles three distinct concerns and it's making review harder than it needs to be:

  1. Prettier tooling setup.prettierrc, .prettierignore, .lintstagedrc, .husky/pre-commit. These are a chore: concern and unrelated to the renderer.
  2. Mass reformat of existing files — all frontend/src/landing/ components (~20 files), globals.css, etc. These are 100% Prettier-driven reflowing (JSX attribute wrapping, CSS transition expansion) with zero logic changes. They're drowning out the real diff.
  3. The actual feature — the React renderer, CORS middleware, daemon discovery, OpenAPI updates, terminal mux. This is the meat, and it's buried.

Suggested split:

  • chore: add prettier config and format existing files — items 1 + 2, reviewable in seconds
  • feat(frontend): rebuild Electron desktop UI as a React + Vite renderer — item 3 only, focused and reviewable

Either a rebase to separate the formatting commit, or extracting items 1+2 into their own PR first works. The formatting-only files can be reviewed with git diff -w in the meantime if you need to unblock.

@yyovil

yyovil commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

@ashish921998 plz keep the PRs tightly scoped with the PR title.

Resolves conflicts with main's prettier config + CI auto-formatter (#166):
- Adopt main's .prettierrc (tabs) — CI reformats every push, so the
  branch's spaces config would just get rewritten
- Take main's versions of frontend/src/landing/** (branch only had
  formatting changes there; now byte-identical to main)
- Keep the branch's frontend/package.json, tsconfig.json, main.ts
  (the Electron redesign), root package.json (husky/lint-staged), and
  .gstack/ gitignore entry; keep main's .envrc.local entry
- Reformat the branch's own files with prettier@3 exactly as CI does,
  so the auto-formatter has nothing to commit after push

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +68 to +69
const nameValid = name === "" || NAME_RULE.test(name);
const canSubmit = prompt.trim().length > 0 && projectId !== "" && nameValid && !isSubmitting;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Worker name blocks submission but is silently discarded

canSubmit gates on nameValid, so a user who types something like "My Worker" (spaces or uppercase letters violate NAME_RULE = /^[a-z0-9-]+$/) cannot submit the form. But the name value is never passed to onCreateTask — it only drives the worktree-path preview in the "Workspace" tab and is reset after a successful submit. The default "" is valid, so a user who doesn't touch the field is unaffected, but anyone who types an entry that doesn't satisfy the regex is silently blocked from spawning a worker for no functional reason.

Either remove nameValid from canSubmit (and keep the visual hint as a non-blocking style signal), or thread name through onCreateTask and the createTask API call so the validation actually enforces a real constraint.

@yyovil yyovil closed this Jun 10, 2026
ashish921998 added a commit that referenced this pull request Jun 11, 2026
…ass, no_signal watchdog (#170)

* fix(codex): deliver activity hooks via -c session flags, trust worktree at launch

Codex (0.136+) never loads hook config from AO's per-session worktrees:
project-local .codex/ layers only load from trusted directories, and for
linked git worktrees codex sources hook declarations from the matching
folder in the root checkout — so the workspace-local .codex/hooks.json AO
wrote was dead config and codex sessions never reported activity.

Deliver the hooks on the launch/resume command instead:
- -c 'hooks.<Event>=[...]' session-flag config for SessionStart,
  UserPromptSubmit, PermissionRequest, and Stop; the session-flags layer
  is not trust-gated and aggregates with the user's own hooks. The
  existing --dangerously-bypass-hook-trust flag lets them run without a
  persisted trust hash.
- -c 'projects={"<worktree>"={trust_level="trusted"}}' (inline-table
  form; the dotted projects."<path>".trust_level key is corrupted by
  codex's naive -c dot-split) so spawns into never-trusted repos don't
  hang invisibly on the interactive directory-trust prompt. Both the
  literal and symlink-resolved worktree paths are trusted.
- -c notice.hide_rate_limit_model_nudge=true so the "switch to a cheaper
  model?" dialog can't hang a headless pane and swallow the spawn prompt.

GetAgentHooks no longer writes workspace files (worktrees stay clean); it
only strips entries older AO versions left in .codex/hooks.json,
preserving user hooks. UninstallHooks/AreHooksInstalled now operate on
those legacy files only.

Verified with a real spawn into a fresh untrusted repo: activity
transitions idle -> active -> idle hands-free, no .codex dir in the
worktree, no hook delivery failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(sessions): activity-signal watchdog + hook delivery hardening

A codex upgrade broke activity tracking silently: sessions showed a
confident "idle" forever while the agent worked. This bundle makes hook
delivery verifiable end to end and makes any future breakage loud
instead of invisible.

Watchdog (no_signal status):
- sessions.first_signal_at (migration 0010) records the FIRST hook
  callback per spawn/restore — raw signal receipt, independent of the
  derived activity state. lifecycle.ApplyActivitySignal stamps it (and
  writes through same-state repeats until stamped, e.g. Codex
  SessionStart reporting idle on an idle-seeded row); MarkSpawned clears
  it so every relaunch re-proves its hook pipeline.
- deriveStatus downgrades a live session with no receipt to the new
  no_signal display status after a 90s grace, instead of idle.
  Terminated/PR-derived statuses still win. The sessions CDC update
  trigger now also fires on first-signal receipt so the dashboard
  transition is pushed live.
- frontend maps no_signal -> needs_you (a human should look at the pane).

Hook callback hardening (re-landed from the closed redesign PR #156):
- the session manager pins each spawned session's PATH with the daemon
  executable's directory first, so the bare `ao` in hook commands
  resolves to the daemon that installed them, with a spawn-time warning
  when the pin cannot apply.
- `ao hooks` failures append to $AO_DATA_DIR/hooks.log (size-capped);
  `ao doctor` gains a hooks-log check that warns on failures from the
  last 24h, and an ao-binary identity check.

Codex launch-surface canary:
- `ao doctor` gains codex-launch-flags: it runs probes exported by the
  codex adapter (built from the same flag builders as the real spawn
  argv) against the installed binary, warning when codex rejects the
  hook-trust bypass flag or AO's -c session-flag overrides.
- codex hook callback timeout drops 30s -> 5s so a hung daemon cannot
  stall the agent's turn.

Docs: the agent PRD callback section now describes the implemented flow
(derive state, POST /sessions/{id}/activity, hooks.log) instead of the
unbuilt SQLite/metadata merge, and notes that hook-derived metadata
persistence (codex resume) is still not implemented.

Frontend note: main's renderer test suite has 7 pre-existing failing
files and a vite-config typecheck error unrelated to this change;
workspace.test.ts (the only frontend file touched) passes 26/26.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(store): restore TestSessionWorktreesRoundTrip lost in the re-landing port

The branch ported store_test.go wholesale from the closed redesign
branch, whose copy predates #165 — silently dropping the
session-worktrees round-trip test #165 added. Restore main's file and
re-apply only this branch's addition (TestSessionFirstSignalRoundTrip).
No other ported file lost main-side content (audited per-file against
main; the remaining deletions are this branch's intended refactors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(status): only derive no_signal for harnesses that have a hook pipeline

Review finding: the no_signal downgrade had no harness-capability gate, but
first_signal_at can only ever be stamped by an `ao hooks` callback. Ten
spawnable harnesses (amp, aider, crush, grok, kimi, devin, auggie, continue,
vibe, pi) install no hooks at all, so every live session of theirs would have
flipped from idle to a permanent no_signal -> needs_you after the 90s grace.

The session service now takes a SignalCapable predicate; daemon wiring injects
activitydispatch.SupportsHarness (the deriver registry is the source of truth
for "this harness can signal"). Left nil, the service never claims no_signal.
A new dispatch test pins that every deriver token is a known harness name.

Also from the same review:
- lifecycle/manager.go and the 0010 migration claimed Codex's SessionStart
  reports idle as the first signal; both codex and claude-code derivers
  deliberately return no signal for session-start, so the comments now cite a
  real case (a lost "active" POST followed by a Stop hook landing idle).
- docs/agent/README.md documents the gate and the restore caveat: a restored
  session the user never prompts has nothing to signal, so it shows no_signal
  after the grace until a receipt-only session-start signal exists.
- 0010 migration uses DROP TRIGGER IF EXISTS per house style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ashish921998 added a commit that referenced this pull request Jun 11, 2026
…ker name (#171)

* fix(spawn): stop sending branch on spawn, render API errors, wire worker name

Three spawn-modal bugs, re-landed from the closed redesign branch (#156):

- createTask no longer sends `branch`: the API field names the session's
  NEW worktree branch, so submitting the modal's default ("main") made the
  daemon 409 with BRANCH_CHECKED_OUT_ELSEWHERE on every spawn. The "Based on"
  pane is informational — workers branch off the project's default branch in
  a fresh worktree.
- API errors render their envelope message instead of "[object Object]":
  openapi-fetch resolves non-2xx responses to a plain {code,error,message}
  object, not an Error; new apiErrorMessage unwraps it (message + code).
- The "Worker name" field is actually used: after spawn, a best-effort
  PATCH rename sets the displayName (a failed rename must not look like a
  failed spawn — the worker is already running).

Also revives the renderer test suite, which made these tests (and 7 of 9
suite files) impossible to run on main:

- vitest.config.ts re-exports vite.renderer.config so `vitest run` actually
  loads the jsdom environment + setup file. Forge's per-target
  vite.*.config.ts names are invisible to vitest, so the existing `test`
  block was dead config and every DOM-touching test died on
  "window is not defined".
- vite.renderer.config.ts imports defineConfig from vitest/config so its
  `test` key typechecks.
- routeTree.gen.ts + the session route are regenerated by the pinned
  @tanstack/router-plugin (it runs as part of loading the renderer config;
  the checked-in tree predated the installed plugin version and its route-ID
  drift caused 3 of main's 7 typecheck errors).
- App.test.tsx wraps App in TooltipProvider, mirroring routes/__root.tsx.

Frontend: 9/9 test files, 99/99 tests pass (was 2/9 files). Typecheck is
down from 7 errors to 3 — the survivors (forge.config notarize/maker types,
update-electron-app call signature) predate this branch and are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

* fix(gitworktree): base new session branches on the local default branch when no remote exists

Re-lands the remoteless fallback from the closed redesign branch (archived
in 641b712). Creating a session worktree resolved the base for a NEW branch
only via the remote-tracking ref (origin/<defaultBranch>), so a registered
repo with no remote failed every spawn with BRANCH_NOT_FETCHED — an error
that misleadingly names the new session branch and suggests `git fetch`,
which is impossible without a remote.

refs/heads/<defaultBranch> now follows origin/<defaultBranch> in the
candidate list: remote-tracking still wins whenever it exists, and a
remoteless repo bases session branches on its local default branch.

Verified live: a plain `git init` repo (no remote) that previously failed
now spawns, and the integration suite covers it
(TestWorkspaceIntegrationCreateInRemotelessRepo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants