feat(frontend): scaffold for frontend with complimentary backend changes (#168)

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

Replaces the skeleton Electron frontend with a full React 19 + TypeScript
renderer (Vite, electron-forge, contextBridge preload), plus the backend
additions it needs.

Renderer:
- TanStack Query + EventTransport (CDC SSE on /api/v1/events)
- TanStack Router file-system routing (hash history for the file:// origin)
- Tailwind + shadcn/ui, react-resizable-panels, Zustand UI state
- @xterm/xterm per-session PTY over /mux WebSocket + WebGL addon
- openapi-typescript + openapi-fetch types off openapi.yaml
- electron-forge packaging + update-electron-app auto-updater
- Vitest + RTL · Playwright

Backend:
- cors.go — allowlist-only CORS, handles Private Network Access preflight
  for app:// renderer -> loopback daemon
- session.TerminalHandleID exposed in domain + OpenAPI spec
- project.Path added to OpenAPI spec, service, store, and tests

DESIGN.md documents the emdash-matched dark UI (tokens, blue accent, status
glyph spec, orchestrator-led layout).

Co-authored-by: Ashish Huddar <ashish.hudar@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: b1e334c1e54a

* chore: format with prettier [skip ci]

---------

Co-authored-by: Ashish Huddar <ashish.hudar@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
yyovil 2026-06-10 11:40:17 +05:30 committed by GitHub
parent 5982051651
commit c2c4404c7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 21123 additions and 35 deletions

43
.github/workflows/frontend-release.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Desktop release
# Builds and publishes the Electron desktop app via electron-forge.
# Generates a GitHub Release (draft) with installers + update manifests.
# Triggered by a `desktop-v*` tag or manually.
#
# ⚠️ Until macOS code signing + notarization secrets are configured (see
# frontend/docs/desktop-release.md), published builds are UNSIGNED and will
# NOT auto-update on macOS. The workflow still produces installable artifacts.
on:
push:
tags:
- "desktop-v*"
workflow_dispatch:
jobs:
release:
runs-on: macos-latest
permissions:
contents: write
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- name: Publish
run: npm run publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# macOS signing + notarization — add as repository secrets and
# set osxSign/osxNotarize in forge.config.ts to enable.
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}

4
.gitignore vendored
View File

@ -49,3 +49,7 @@ Thumbs.db
# Personal local overrides (not for the team) # Personal local overrides (not for the team)
.envrc.local .envrc.local
# electron-forge / vite build output
.vite/
dist-electron/

View File

@ -1,3 +1,11 @@
# CLAUDE.md # CLAUDE.md
Read and follow [`AGENTS.md`](AGENTS.md) for repository layout, commands, coding conventions, and hard rules. Read and follow [`AGENTS.md`](AGENTS.md) for repository layout, commands, coding conventions, and hard rules.
## Design System
Always read [`DESIGN.md`](DESIGN.md) before making any visual or UI decision.
All fonts, colors, spacing, layout, the orchestrator-led flow, the topbar, and the
spawn modal are defined there. The UI matches **emdash** with one main orchestrator and
a refined-blue accent. Do not deviate without explicit user approval. In QA/review,
flag any renderer code that doesn't match DESIGN.md.

226
DESIGN.md Normal file
View File

@ -0,0 +1,226 @@
# Design System — ReverbCode
> Source of truth for the ReverbCode desktop UI (Electron + React 19 + Tailwind v4
>
> - Radix/shadcn + xterm, in `frontend/src/renderer`). Read this before any visual
> or UI change. Created by `/design-consultation` on 2026-06-09.
## Product Context
- **What this is:** ReverbCode is an Electron desktop app for supervising many parallel
AI coding-agent sessions, backed by a Go daemon (`backend/`). The `ao` CLI is the
thin client over the same daemon.
- **Who it's for:** professional software engineers running multiple coding agents at
once who need to delegate, watch, intervene, and ship PRs.
- **Space/peers:** agent orchestration / parallel-agent desktop tools. Closest peers:
**emdash** (the primary design reference), **PostHog Code**, Conductor.
- **Project type:** dark-mode-primary desktop app; terminal-dense; keyboard-driven;
runs all day.
- **The one memorable thing:** leverage and speed — "I'm more in control here than
babysitting N terminal tabs myself."
### Product flow (what the UI must serve)
ReverbCode is **orchestrator-led**, which is the one thing that differs from emdash
(a flat list of independent sessions). Grounded in the daemon
(`backend/internal/session_manager/manager.go`, `docs/architecture.md`):
- A **Project** is a registered git repo.
- Per project there is **one active Orchestrator** session plus **N Worker** sessions.
Both are the same underlying "session" (durable facts: `activity_state`,
`is_terminated`, PR facts); they differ only by `Kind` (`KindOrchestrator` vs the
default worker). A project may run the orchestrator on a different agent than its workers.
- The **Orchestrator is the human-facing coordinator**: you talk to it; it spawns
workers (`ao spawn`), messages them (`ao send`), tracks progress, and synthesizes
results. It avoids implementing unless necessary.
- A **Worker is a normal agent session** — nothing special-cased. It runs one focused
task in an isolated git worktree + branch, with the agent CLI in a terminal as the
conversation, producing a diff → commit/push → PR. It escalates to the orchestrator
only for true blockers or cross-session coordination.
- The daemon **observes** runtime + PR/CI/review facts and **derives** display status
at read time: `working`, `needs_input`, `ci_failed`, `changes_requested`,
`mergeable`, `approved`, `review_pending`, `pr_open`, `idle`, `terminated`, `merged`.
Never store display status; keep session facts small.
## Aesthetic Direction
- **Direction:** match **emdash** exactly — flat, near-black, hairline-bordered,
utilitarian. Industrial control surface, calm chrome, the terminal as the center of gravity.
- **Decoration level:** minimal. Type + 1px hairlines do all the work. No gradients,
glow, blobs, or emoji.
- **Mood:** low-glare, dense, keyboard-native; signal-over-noise.
- **Reference:** [emdash](https://github.com/generalaction/emdash) (primary, visual +
structural), [PostHog Code](https://github.com/PostHog/code) (secondary). Tokens
below were extracted from emdash's `src/renderer/index.css`.
- **Deliberate tradeoff:** to _be_ emdash, we use the **system font stack** (not a
custom typeface) and emdash's neutral palette. We diverge in exactly one place: the
accent is ReverbCode's **refined blue**, not emdash's jade green. The terminal keeps
green (it is the agent CLI).
## Typography
System fonts only, like emdash — no custom/Google fonts, zero font payload.
- **UI / body / display:** `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Fira Sans", "Helvetica Neue", sans-serif` (San Francisco
on macOS).
- **Mono / terminal / code / eyebrow labels:** `Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace`.
- **Eyebrow labels** (section titles, dialog titles, the rail "PROJECTS" header):
mono, **uppercase**, `letter-spacing: .12.14em`, `--foreground-passive`.
- **Scale:** 14px base UI / sidebar (`text-sm`, weight 400) · 12px secondary + labels
(`text-xs`) · 13px code/mono/terminal · 11px tiny · 10px micro + badges · 9px sidebar
badge label. Buttons are `font-normal` (400), not bold.
## Color
emdash's flat Radix-neutral near-black ramp carries the whole interface; color is rare
and meaningful. Values are sRGB approximations of emdash's `color(display-p3 …)` tokens.
### Dark (primary)
| Role | Hex |
| ------------------------------------ | --------------- |
| `--bg` canvas | `#111111` |
| `--bg-1` surface | `#191919` |
| `--bg-2` raised / hover / active row | `#222222` |
| `--bg-3` | `#2a2a2a` |
| `--fg` text | `#eeeeee` |
| `--fg-muted` | `#b4b4b4` |
| `--fg-passive` | `#6e6e6e` |
| `--border` hairline | `#3a3a3a` |
| `--border-1` | `#484848` |
| **`--accent` (blue)** | **`#5b9dff`** |
| `--needs-you` / in-progress (amber) | `#ffcc4a` |
| `--success` / mergeable (green) | `#6cb16c` |
| terminal green | `#7bd88f` |
| `--error` (red) | `#d4544f` |
| text selection | `#3f8ef7` @ 35% |
| terminal bg | `#161616` |
### Light (supported, not primary)
| Role | Hex |
| ------------------------- | --------------------------------- |
| canvas / surface / raised | `#fcfcfc` / `#ffffff` / `#ededee` |
| text / muted / passive | `#1a1a1a` / `#666666` / `#9a9a9a` |
| border | `#e3e3e5` |
| accent (blue) | `#2563eb` |
| amber / green / red | `#9a6b00` / `#1a7f37` / `#c0392b` |
### Accent rules
- **Blue** = the live edge only: primary buttons, the active/selected session, focus
rings. Never decorative.
- **Amber** = an agent needs you (blocked / `needs_input` / `review_pending`).
- **Green** = `mergeable`/success and terminal/agent CLI text.
- **Red** = `ci_failed` / destructive.
- These map 1:1 to the daemon's derived statuses.
### Status indicator (no text badges)
Session status is a single ~14px glyph in one fixed slot, never a text pill/badge:
- **Working / active** → an animated spinner (accent).
- **Has an open PR** → a PR icon, tinted by PR state: mergeable/approved green,
`ci_failed` red, review/`changes_requested` amber, plain `pr_open` muted.
- **Otherwise** → a filled dot: `needs_input` amber (pulsing), idle/done muted gray.
Precedence: **working spinner > PR icon > dot**. Implemented as `StatusGlyph` in
`components/SideRail.tsx`; used in the orchestrator's Workers list. (Worker rows in the
left rail stay name-only — no glyph.)
## Spacing
- **Base unit:** 4px (Tailwind scale: 1=4, 1.5=6, 2=8, 3=12, 4=16, 5=20, 6=24).
- **Density:** compact / desktop-tight.
- **Control + row height:** `h-8` = 32px default; `h-7` = 28px small; `h-6` = 24px xs.
- Inputs `px-2.5 py-1`; buttons `px-2.5`, gap 11.5.
## Layout
- **Approach:** fixed three-pane app shell, opens into the workbench (no marketing/dashboard home).
- **Panes:** `[ rail 240px ] [ center 1fr ] [ side rail 316px ]`.
- **Rail (240px), top → bottom:**
1. **Orchestrator anchor** — pinned, single, visually distinct (blue 2px left bar,
`--bg-2` fill, hub/`waypoints` icon, name "Orchestrator", a `5 agents · 2 need you`
mono summary). This is ReverbCode's one addition over emdash. Default landing view.
2. `PROJECTS` eyebrow label + a `+`.
3. Project rows (folder icon + name) with nested **worker rows beneath**. Each project
row has a hover-revealed **`+`** that opens the New-worker modal pre-scoped to that
project (distinct from the `PROJECTS` header `+`, which registers a repo).
4. **Footer:** `Search ⌘K`, `Settings ⌘,`. (No Library.)
5. **Account** row pinned at the very bottom.
- **Worker rows are name-only.** Just the session name, truncated. Status, branch, diff,
and PR live in the panes and topbar, never in the row. Selection = `--bg-2` fill + a
2px blue left bar. (emdash itself shows a faint trailing timestamp; we omit it by choice.)
- **Center = the conversation.** Orchestrator → its coordination terminal (delegate here;
composer reads "tell the orchestrator what to build"). Worker → the agent CLI terminal
(tabbed per agent, e.g. `claude-code (1)`), with a composer (model selector, worktree
path, `Accept edits`). The terminal **is** the conversation; no separate chat surface.
- **Side rail (316px):** orchestrator → a quiet **Workers** list (name + project + derived
status). Worker → the **Git review rail**: `Changed N` → All files / Discard all / Stage
all → file rows (`+adds dels`, stage toggle) → `Commit message` + `Description`
**Commit & Push** (primary blue) → branch + `Create PR`.
- **Border radius:** `sm` 4px (scrollbar) · `md` 6px (buttons, inputs, toggles) ·
`lg` 8px (rows, cards, panels) · `xl` 12px (modals) · `full` (badges/pills/dots).
- **Icons:** **lucide** only. No emoji.
### Topbar
- **Left (both):** `project / session` breadcrumb + pin; for the orchestrator, a hub icon
- `Orchestrator`.
- **Right — worker session:** a **PR/CI status pill** that is the action
(`PR #156 · mergeable` green / `CI failed` red / `review requested` amber /
`Open PR` when none) → **Changes / Files / Terminal** view toggles → **⋯ session menu**
(rename, restart, kill, claim PR — the `ao session …` commands).
- **Right — orchestrator:** **+ New worker** → Terminal toggle → **⋯ menu**. No diff toggles.
### Spawn-worker modal (mirrors emdash's Create Task)
You mostly let the orchestrator spawn workers from its conversation; the manual paths
(the topbar `+ New worker`, a project row's hover `+`, or `ao spawn`) open a modal that
mirrors emdash exactly. Launching from a project row pre-fills the Project field:
- Centered dialog, **12px radius**, `max-w` ~512px, `bg` canvas, `ring-1` at 10% fg,
fade + zoom-95 enter.
- **Header:** eyebrow mono-uppercase title `New worker` + `×` close.
- **Body** (`gap` 1516px): a **borderless large name field** (18px, auto-focus, slug
rule "letters, numbers, hyphens") → **Project** selector → **Agent** selector
(claude-code / codex / opencode / …) → a **"Based on"** bordered card with a segmented
control `Branch · Issue · Pull Request` revealing a combobox → a **Prompt / Workspace**
tab where Prompt is the worker's initial task (textarea).
- **Footer:** right-aligned single primary **`Spawn worker`** (blue) with a `⌘↵` keycap,
disabled until valid.
## Motion
- **Approach:** minimal-functional. The one expressive exception: a status dot/spinner
pulse on active/working sessions (opacity breathe) so "alive" is glanceable. Never
animate text or layout.
- **Easing:** enter `ease-out`, exit `ease-in`, move `ease-in-out`.
- **Duration:** micro 80ms · short 160ms · medium 240ms · status pulse 1.8s loop ·
modal enter ~150ms fade+zoom-95.
## Implementation notes
- The renderer (`frontend/src/renderer/styles.css`) currently uses **Inter** and a
grayscale-blue theme. Migrate to this system: drop the Inter `font-family`, adopt the
system stack, and replace the token values with the emdash neutral ramp + blue accent above.
- Keep tokens as CSS custom properties under `:root` (dark) and `:root[data-theme="light"]`.
- A faithful HTML reference of all of the above (both views + topbar + spawn modal,
light/dark) is saved under
`~/.gstack/projects/aoagents-agent-orchestrator/designs/design-system-20260609/`.
## Decisions Log
| Date | Decision | Rationale |
| ---------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| 2026-06-09 | Match emdash's visual language exactly | User direction; emdash is the demonstrated reference for this app's UI. |
| 2026-06-09 | System font, not a custom typeface (e.g. Geist) | emdash uses the system stack; fidelity + native feel + zero font payload chosen over brand type. |
| 2026-06-09 | Refined **blue** accent, not emdash's jade green | User's explicit pick; blue for primary/active/focus, terminal stays green. |
| 2026-06-09 | Single global **Orchestrator** anchor, orchestrator-first default view | The one real difference from emdash; orchestrator is the human-facing coordinator you delegate to. |
| 2026-06-09 | **Name-only** worker rows | User direction; status/branch/diff live in panes + topbar, not the row. |
| 2026-06-09 | Removed **Library** from the rail footer | User direction; footer is Search + Settings only. |
| 2026-06-09 | Topbar right = PR/CI pill + view toggles + ⋯ menu (worker) | Surfaces the actionable PR/CI state from the daemon; emdash/PostHog Code precedent. |
| 2026-06-09 | Spawn modal mirrors emdash's Create Task | Consistency with the reference; mapped to `ao spawn` params. |

View File

@ -10,6 +10,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"time" "time"
) )
@ -33,6 +34,18 @@ const (
DefaultAgent = "claude-code" DefaultAgent = "claude-code"
) )
// DefaultAllowedOrigins are the browser origins the daemon's CORS boundary
// trusts, beyond loopback-served content (which the middleware always trusts —
// local pages can reach the no-auth daemon directly anyway). The daemon has no
// auth, so every entry must be an origin web content cannot present:
// app://renderer is the packaged Electron renderer, served from a custom
// scheme only the desktop app registers — no website can bear it. The opaque
// "null" origin (file:// pages, sandboxed iframes on any website) must never
// be added.
var DefaultAllowedOrigins = []string{
"app://renderer",
}
// Config is the fully-resolved daemon configuration. It is immutable once // Config is the fully-resolved daemon configuration. It is immutable once
// built by Load. // built by Load.
type Config struct { type Config struct {
@ -54,6 +67,9 @@ type Config struct {
// Manager (see DefaultAgent). Selected by AO_AGENT; startSession fails fast // Manager (see DefaultAgent). Selected by AO_AGENT; startSession fails fast
// if no adapter with this id is registered. // if no adapter with this id is registered.
Agent string Agent string
// AllowedOrigins are the browser origins granted CORS read access (see
// DefaultAllowedOrigins). Overridden by AO_ALLOWED_ORIGINS.
AllowedOrigins []string
} }
// Addr returns the host:port the HTTP server binds. It uses net.JoinHostPort so // Addr returns the host:port the HTTP server binds. It uses net.JoinHostPort so
@ -74,6 +90,7 @@ func (c Config) Addr() string {
// AO_RUN_FILE running.json path (default <state-dir>/running.json) // AO_RUN_FILE running.json path (default <state-dir>/running.json)
// AO_DATA_DIR durable state dir (default <state-dir>/data) // AO_DATA_DIR durable state dir (default <state-dir>/data)
// AO_AGENT agent adapter id (default claude-code) // AO_AGENT agent adapter id (default claude-code)
// AO_ALLOWED_ORIGINS CORS origins, comma-separated (default DefaultAllowedOrigins)
// //
// The bind host is not configurable: the daemon is loopback-only by design. // The bind host is not configurable: the daemon is loopback-only by design.
func Load() (Config, error) { func Load() (Config, error) {
@ -83,6 +100,7 @@ func Load() (Config, error) {
RequestTimeout: DefaultRequestTimeout, RequestTimeout: DefaultRequestTimeout,
ShutdownTimeout: DefaultShutdownTimeout, ShutdownTimeout: DefaultShutdownTimeout,
Agent: DefaultAgent, Agent: DefaultAgent,
AllowedOrigins: DefaultAllowedOrigins,
} }
if raw := os.Getenv("AO_PORT"); raw != "" { if raw := os.Getenv("AO_PORT"); raw != "" {
@ -116,6 +134,25 @@ func Load() (Config, error) {
cfg.Agent = raw cfg.Agent = raw
} }
if raw, ok := os.LookupEnv("AO_ALLOWED_ORIGINS"); ok && raw != "" {
// Explicit override replaces the defaults entirely so a deployment can
// also narrow the list. The "null" origin is rejected, never silently
// dropped: an operator allowing it would open the no-auth daemon to
// every sandboxed iframe on the web.
origins := make([]string, 0, 4)
for _, origin := range strings.Split(raw, ",") {
origin = strings.TrimSpace(origin)
if origin == "" {
continue
}
if origin == "null" || origin == "*" {
return Config{}, fmt.Errorf("invalid AO_ALLOWED_ORIGINS entry %q: wildcard and null origins are not allowed", origin)
}
origins = append(origins, origin)
}
cfg.AllowedOrigins = origins
}
runFile, err := resolveRunFilePath() runFile, err := resolveRunFilePath()
if err != nil { if err != nil {
return Config{}, err return Config{}, err

View File

@ -85,6 +85,8 @@ func TestLoadInvalid(t *testing.T) {
{"negative request timeout", map[string]string{"AO_REQUEST_TIMEOUT": "-1s"}}, {"negative request timeout", map[string]string{"AO_REQUEST_TIMEOUT": "-1s"}},
{"zero shutdown timeout", map[string]string{"AO_SHUTDOWN_TIMEOUT": "0s"}}, {"zero shutdown timeout", map[string]string{"AO_SHUTDOWN_TIMEOUT": "0s"}},
{"negative shutdown timeout", map[string]string{"AO_SHUTDOWN_TIMEOUT": "-5s"}}, {"negative shutdown timeout", map[string]string{"AO_SHUTDOWN_TIMEOUT": "-5s"}},
{"null origin", map[string]string{"AO_ALLOWED_ORIGINS": "app://renderer,null"}},
{"wildcard origin", map[string]string{"AO_ALLOWED_ORIGINS": "*"}},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@ -97,3 +99,39 @@ func TestLoadInvalid(t *testing.T) {
}) })
} }
} }
func TestLoadAllowedOrigins(t *testing.T) {
t.Run("default includes the packaged renderer origin", func(t *testing.T) {
t.Setenv("AO_ALLOWED_ORIGINS", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
found := false
for _, origin := range cfg.AllowedOrigins {
if origin == "app://renderer" {
found = true
}
}
if !found {
t.Errorf("AllowedOrigins = %v, want app://renderer included", cfg.AllowedOrigins)
}
})
t.Run("override replaces defaults and trims entries", func(t *testing.T) {
t.Setenv("AO_ALLOWED_ORIGINS", " app://renderer , http://localhost:9999 ,")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
want := []string{"app://renderer", "http://localhost:9999"}
if len(cfg.AllowedOrigins) != len(want) {
t.Fatalf("AllowedOrigins = %v, want %v", cfg.AllowedOrigins, want)
}
for i, origin := range want {
if cfg.AllowedOrigins[i] != origin {
t.Errorf("AllowedOrigins[%d] = %q, want %q", i, cfg.AllowedOrigins[i], origin)
}
}
})
}

View File

@ -53,5 +53,6 @@ type SessionRecord struct {
// plus the derived display Status. // plus the derived display Status.
type Session struct { type Session struct {
SessionRecord SessionRecord
Status SessionStatus `json:"status"` Status SessionStatus `json:"status"`
TerminalHandleID string `json:"terminalHandleId,omitempty"`
} }

View File

@ -1203,6 +1203,8 @@ components:
type: string type: string
name: name:
type: string type: string
path:
type: string
resolveError: resolveError:
type: string type: string
sessionPrefix: sessionPrefix:
@ -1210,6 +1212,7 @@ components:
required: required:
- id - id
- name - name
- path
- sessionPrefix - sessionPrefix
type: object type: object
RemoveProjectResult: RemoveProjectResult:
@ -1332,6 +1335,8 @@ components:
type: string type: string
status: status:
type: string type: string
terminalHandleId:
type: string
updatedAt: updatedAt:
format: date-time format: date-time
type: string type: string

View File

@ -186,6 +186,15 @@ func TestProjectsAPI_ListAddGet(t *testing.T) {
} }
body, status, _ = doRequest(t, srv, "GET", "/api/v1/projects", "")
if status != http.StatusOK {
t.Fatalf("GET projects after add = %d, want 200; body=%s", status, body)
}
mustJSON(t, body, &list)
if len(list.Projects) != 1 || list.Projects[0].Path != repo {
t.Fatalf("project summary path = %#v, want path %q", list.Projects, repo)
}
} }
func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) { func TestProjectsAPI_AddValidationAndConflicts(t *testing.T) {
@ -407,6 +416,8 @@ type projectSummary struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"`
SessionPrefix string `json:"sessionPrefix"` SessionPrefix string `json:"sessionPrefix"`
} }

View File

@ -28,7 +28,7 @@ type fakeSessionService struct {
func newFakeSessionService() *fakeSessionService { func newFakeSessionService() *fakeSessionService {
now := time.Now().UTC() now := time.Now().UTC()
s := domain.Session{SessionRecord: domain.SessionRecord{ID: "ao-1", ProjectID: "ao", Kind: domain.KindWorker, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle} s := domain.Session{SessionRecord: domain.SessionRecord{ID: "ao-1", ProjectID: "ao", Kind: domain.KindWorker, Activity: domain.Activity{State: domain.ActivityIdle, LastActivityAt: now}, CreatedAt: now, UpdatedAt: now}, Status: domain.StatusIdle, TerminalHandleID: "ao-1/terminal_0"}
return &fakeSessionService{sessions: map[domain.SessionID]domain.Session{s.ID: s}} return &fakeSessionService{sessions: map[domain.SessionID]domain.Session{s.ID: s}}
} }
@ -174,7 +174,7 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) {
Sessions []sessionBody `json:"sessions"` Sessions []sessionBody `json:"sessions"`
} }
mustJSON(t, body, &list) mustJSON(t, body, &list)
if len(list.Sessions) != 1 || list.Sessions[0].ID != "ao-1" || list.Sessions[0].Status != string(domain.StatusIdle) { if len(list.Sessions) != 1 || list.Sessions[0].ID != "ao-1" || list.Sessions[0].Status != string(domain.StatusIdle) || list.Sessions[0].TerminalHandleID != "ao-1/terminal_0" {
t.Fatalf("list = %#v", list) t.Fatalf("list = %#v", list)
} }
@ -359,13 +359,14 @@ func TestSessionsAPI_CleanupWithoutProjectFilter(t *testing.T) {
} }
type sessionBody struct { type sessionBody struct {
ID string `json:"id"` ID string `json:"id"`
ProjectID string `json:"projectId"` ProjectID string `json:"projectId"`
IssueID string `json:"issueId"` IssueID string `json:"issueId"`
Kind string `json:"kind"` Kind string `json:"kind"`
Harness string `json:"harness"` Harness string `json:"harness"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Status string `json:"status"` Status string `json:"status"`
TerminalHandleID string `json:"terminalHandleId"`
} }
func TestSessionsAPI_PRRoutes(t *testing.T) { func TestSessionsAPI_PRRoutes(t *testing.T) {

View File

@ -0,0 +1,94 @@
package httpd
import (
"net"
"net/http"
"net/url"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
)
// corsMiddleware grants cross-origin read access to the allowlisted browser
// origins only. The daemon is a no-auth loopback service, so CORS is the one
// boundary between it and hostile browser content running on the same
// machine: the allowlist must never contain "*" or the opaque "null" origin
// (every file:// page and sandboxed iframe on any website presents "null").
// The packaged Electron renderer is served from app://renderer specifically
// so it has a distinct, unforgeable origin this allowlist can name.
//
// Requests without an Origin header (the CLI, curl, health probes) pass
// through untouched. Requests bearing an Origin outside the allowlist are
// rejected with 403 before any handler runs: merely omitting CORS headers
// would hide the response but NOT the side effect — a hostile page can issue
// "simple" cross-origin POSTs (no-cors mode, text/plain body) that handlers
// would otherwise execute. Same philosophy as localControlRequest on
// /shutdown, applied to the whole surface.
func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
allowed := make(map[string]struct{}, len(allowedOrigins))
for _, origin := range allowedOrigins {
origin = strings.TrimSpace(origin)
if origin == "" || origin == "null" || origin == "*" {
continue
}
allowed[origin] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
// Cache keys must split on Origin even for rejected values, or a
// 403 could be replayed to an allowed origin.
w.Header().Add("Vary", "Origin")
if _, ok := allowed[origin]; !ok && !isLoopbackOrigin(origin) {
envelope.WriteAPIError(w, r, http.StatusForbidden, "forbidden", "ORIGIN_FORBIDDEN",
"Origin is not allowed to access this daemon", nil)
return
}
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS")
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
h.Set("Access-Control-Allow-Headers", reqHeaders)
}
h.Set("Access-Control-Max-Age", "600")
// Chromium's Private Network Access preflight for requests
// reaching loopback from a less-private address space.
if r.Header.Get("Access-Control-Request-Private-Network") == "true" {
h.Set("Access-Control-Allow-Private-Network", "true")
}
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// isLoopbackOrigin reports whether a browser origin is content served from
// this machine's loopback (the Vite dev server / preview server on whatever
// port it picked). Such content can already reach the no-auth daemon directly,
// so granting it CORS adds no exposure — while a remote page can never bear a
// loopback origin (DNS rebinding changes resolution, not the Origin header).
func isLoopbackOrigin(origin string) bool {
u, err := url.Parse(origin)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return false
}
host := u.Hostname()
if host == "localhost" {
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return false
}

View File

@ -0,0 +1,173 @@
package httpd
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
)
// TestCORS exercises the allowlist boundary on a real router: trusted origins
// get per-origin CORS headers (REST reads and preflights), everything else —
// including the opaque "null" origin and no-Origin CLI traffic — gets none.
func TestCORS(t *testing.T) {
cfg := config.Config{AllowedOrigins: []string{"app://renderer"}}
router := newTestRouter(cfg, discardLogger(), nil)
srv := httptest.NewServer(router)
defer srv.Close()
tests := []struct {
name string
method string
headers map[string]string
wantStatus int
wantACAO string
}{
{
name: "allowed origin gets ACAO",
method: http.MethodGet,
headers: map[string]string{"Origin": "app://renderer"},
wantStatus: http.StatusOK,
wantACAO: "app://renderer",
},
{
// Not in the allowlist — trusted because loopback-served content
// can already reach the daemon directly (dev/preview servers on
// arbitrary ports).
name: "loopback origin allowed without an allowlist entry",
method: http.MethodGet,
headers: map[string]string{"Origin": "http://localhost:5181"},
wantStatus: http.StatusOK,
wantACAO: "http://localhost:5181",
},
{
name: "loopback IP origin allowed",
method: http.MethodGet,
headers: map[string]string{"Origin": "http://127.0.0.1:8080"},
wantStatus: http.StatusOK,
wantACAO: "http://127.0.0.1:8080",
},
{
// localhost in the host position of a non-loopback origin must not
// fool the predicate.
name: "lookalike origin rejected",
method: http.MethodGet,
headers: map[string]string{"Origin": "http://localhost.evil.example"},
wantStatus: http.StatusForbidden,
wantACAO: "",
},
{
// Rejected outright, not just denied CORS headers: a missing ACAO
// hides the response but a "simple" cross-origin POST would still
// execute the handler on this no-auth daemon.
name: "unknown origin is rejected before handlers",
method: http.MethodGet,
headers: map[string]string{"Origin": "http://evil.example"},
wantStatus: http.StatusForbidden,
wantACAO: "",
},
{
name: "null origin is rejected",
method: http.MethodGet,
headers: map[string]string{"Origin": "null"},
wantStatus: http.StatusForbidden,
wantACAO: "",
},
{
name: "no origin passes through untouched",
method: http.MethodGet,
headers: nil,
wantStatus: http.StatusOK,
wantACAO: "",
},
{
name: "preflight from allowed origin",
method: http.MethodOptions,
headers: map[string]string{
"Origin": "app://renderer",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type",
},
wantStatus: http.StatusNoContent,
wantACAO: "app://renderer",
},
{
name: "preflight from unknown origin is rejected",
method: http.MethodOptions,
headers: map[string]string{
"Origin": "http://evil.example",
"Access-Control-Request-Method": "POST",
},
wantStatus: http.StatusForbidden,
wantACAO: "",
},
}
client := &http.Client{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest(tt.method, srv.URL+"/healthz", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
for k, v := range tt.headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("%s /healthz: %v", tt.method, err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != tt.wantACAO {
t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, tt.wantACAO)
}
if tt.headers["Origin"] != "" && resp.Header.Get("Vary") == "" {
t.Error("Vary header missing for request with Origin")
}
})
}
}
// TestCORSPreflightHeaders pins the preflight grant shape: methods, echoed
// request headers, max-age, and the private-network opt-in.
func TestCORSPreflightHeaders(t *testing.T) {
cfg := config.Config{AllowedOrigins: []string{"app://renderer"}}
router := newTestRouter(cfg, discardLogger(), nil)
srv := httptest.NewServer(router)
defer srv.Close()
req, err := http.NewRequest(http.MethodOptions, srv.URL+"/api/v1/sessions", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
req.Header.Set("Origin", "app://renderer")
req.Header.Set("Access-Control-Request-Method", "POST")
req.Header.Set("Access-Control-Request-Headers", "content-type")
req.Header.Set("Access-Control-Request-Private-Network", "true")
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatalf("OPTIONS: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, want 204", resp.StatusCode)
}
for header, want := range map[string]string{
"Access-Control-Allow-Origin": "app://renderer",
"Access-Control-Allow-Methods": "GET, POST, PATCH, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "content-type",
"Access-Control-Max-Age": "600",
"Access-Control-Allow-Private-Network": "true",
} {
if got := resp.Header.Get(header); got != want {
t.Errorf("%s = %q, want %q", header, got, want)
}
}
}

View File

@ -34,6 +34,7 @@ type ControlDeps struct {
// RequestID → attach a request id for correlation // RequestID → attach a request id for correlation
// requestLogger → slog-backed access log, stderr, carries the request id // requestLogger → slog-backed access log, stderr, carries the request id
// RealIP → normalise client IP (loopback proxy from the dev server) // RealIP → normalise client IP (loopback proxy from the dev server)
// cors → CORS allowlist for the Electron renderer / dev origins
// //
// The per-request timeout is deliberately not global: it wraps only bounded // The per-request timeout is deliberately not global: it wraps only bounded
// REST routes, never long-lived terminal streams or health probes. // REST routes, never long-lived terminal streams or health probes.
@ -45,6 +46,7 @@ func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
r.Use(requestLogger(log)) r.Use(requestLogger(log))
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
r.Use(corsMiddleware(cfg.AllowedOrigins))
// JSON envelopes for unmatched routes / methods — chi's defaults are // JSON envelopes for unmatched routes / methods — chi's defaults are
// text/plain, which would break consumers that parse every response as // text/plain, which would break consumers that parse every response as

View File

@ -58,6 +58,7 @@ func (m *Service) List(ctx context.Context) ([]Summary, error) {
out = append(out, Summary{ out = append(out, Summary{
ID: domain.ProjectID(row.ID), ID: domain.ProjectID(row.ID),
Name: displayName(row), Name: displayName(row),
Path: row.Path,
SessionPrefix: resolveSessionPrefix(row), SessionPrefix: resolveSessionPrefix(row),
}) })
} }

View File

@ -6,6 +6,7 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
type Summary struct { type Summary struct {
ID domain.ProjectID `json:"id"` ID domain.ProjectID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"`
SessionPrefix string `json:"sessionPrefix"` SessionPrefix string `json:"sessionPrefix"`
ResolveError string `json:"resolveError,omitempty"` ResolveError string `json:"resolveError,omitempty"`
} }

View File

@ -301,7 +301,7 @@ func (s *Service) toSession(ctx context.Context, rec domain.SessionRecord) (doma
return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err) return domain.Session{}, fmt.Errorf("pr facts %s: %w", rec.ID, err)
} }
if !ok { if !ok {
return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, nil)}, nil return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, nil), TerminalHandleID: rec.Metadata.RuntimeHandleID}, nil
} }
return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, &pr)}, nil return domain.Session{SessionRecord: rec, Status: deriveStatus(rec, &pr), TerminalHandleID: rec.Metadata.RuntimeHandleID}, nil
} }

View File

@ -0,0 +1,48 @@
# Desktop release & auto-update
The desktop app ships an in-app auto-updater (`update-electron-app`). The **code**
is wired; making it **go live** needs infrastructure only the team can provision
(an Apple Developer certificate, notarization, and CI secrets). This is the
checklist.
## What already works (in this repo)
- `update-electron-app` is wired in `src/main.ts` (`initAutoUpdates()`), guarded
by `app.isPackaged` so it is a no-op in `npm run dev`. It reads the GitHub
Releases feed directly via the Releases API — no `latest-mac.yml` files needed.
- `forge.config.ts > publishers` uses `@electron-forge/publisher-github`, pointed
at the GitHub Releases feed (draft releases by default).
- `.github/workflows/frontend-release.yml` builds on a `desktop-v*` tag and runs
`npm run publish` (`electron-forge publish`), which makes the installers and
uploads them to a GitHub Release.
## What the team must add (auto-update is inert until these exist)
1. **Apple Developer cert + notarization** (macOS hard requirement — an unsigned
app cannot auto-update):
- Enroll in the Apple Developer Program.
- Export a "Developer ID Application" certificate as a `.p12`.
- Signing is already gated in `forge.config.ts` on the env vars below:
`osxSign` activates when `CSC_LINK` is set, `osxNotarize` when `APPLE_ID`
is set. No config edit needed — just provide the secrets.
2. **GitHub repository secrets** (Settings → Secrets → Actions):
- `CSC_LINK` — base64 of the `.p12` certificate.
- `CSC_KEY_PASSWORD` — the `.p12` password.
- `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID` — for notarization.
- `GITHUB_TOKEN` is provided automatically; the workflow already grants
`contents: write` to publish the Release.
3. **(Optional) Windows / Linux** — the `forge.config.ts` makers already include
Squirrel (Windows), deb, and rpm. To publish them, add the matching matrix
runners to `frontend-release.yml`; Windows signing needs its own certificate.
## Cutting a release
```bash
# bump frontend/package.json "version", commit, then:
git tag desktop-v0.1.0
git push origin desktop-v0.1.0
```
The workflow publishes a GitHub Release with the installers. Installed apps check
the Releases feed on launch (`update-electron-app`) and prompt to restart when an
update is downloaded.

View File

@ -0,0 +1,30 @@
import { expect, test } from "@playwright/test";
// The Playwright web server runs `dev:web` (VITE_NO_ELECTRON=1), so
// useWorkspaceQuery serves the deterministic preview fixtures from
// lib/mock-data.ts instead of hitting a daemon. The tests run in Chromium
// (no window.ao), so the terminal shows its browser-preview surface.
test("renders the orchestrator-first workbench shell", async ({ page }) => {
await page.goto("/");
// The single pinned Orchestrator anchor + the Projects group + a name-only worker row.
await expect(page.getByRole("button", { name: "Orchestrator", exact: true })).toBeVisible();
await expect(page.getByText("Projects")).toBeVisible();
await expect(page.getByRole("button", { name: "fix-webgl-fallback", exact: true })).toBeVisible();
// Orchestrator side rail = the quiet Workers list.
await expect(page.getByText("Workers", { exact: true })).toBeVisible();
});
test("deep-links into a worker session", async ({ page }) => {
await page.goto("/#/workspaces/api-gateway/sessions/refactor-mux");
// Worker view = emdash three-pane with the Git review rail.
await expect(page.getByText("Changed")).toBeVisible();
await expect(page.getByRole("button", { name: /Commit & Push/ })).toBeVisible();
});
test("drilling into a worker opens its Git review rail", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "refactor-mux", exact: true }).click();
await expect(page.getByRole("button", { name: /Commit & Push/ })).toBeVisible();
await expect(page.getByText("internal/mux/terminal_mux.go")).toBeVisible();
});

52
frontend/forge.config.ts Normal file
View File

@ -0,0 +1,52 @@
import type { ForgeConfig } from "@electron-forge/shared-types";
import { VitePlugin } from "@electron-forge/plugin-vite";
const config: ForgeConfig = {
packagerConfig: {
asar: true,
appBundleId: "dev.agent-orchestrator.desktop",
name: "Agent Orchestrator",
executableName: "agent-orchestrator",
appCategoryType: "public.app-category.developer-tools",
// macOS signing + notarization — set CSC_LINK/CSC_KEY_PASSWORD and
// APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID in CI.
// See frontend/docs/desktop-release.md.
osxSign: process.env.CSC_LINK ? {} : undefined,
osxNotarize: process.env.APPLE_ID
? {
tool: "notarytool",
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD!,
teamId: process.env.APPLE_TEAM_ID!,
}
: undefined,
},
rebuildConfig: {},
makers: [
{ name: "@electron-forge/maker-squirrel", config: { name: "AgentOrchestrator" } },
{ name: "@electron-forge/maker-zip", platforms: ["darwin"] },
{ name: "@electron-forge/maker-deb", config: {} },
{ name: "@electron-forge/maker-rpm", config: {} },
],
publishers: [
{
name: "@electron-forge/publisher-github",
config: {
repository: { owner: "aoagents", name: "agent-orchestrator" },
prerelease: false,
draft: true,
},
},
],
plugins: [
new VitePlugin({
build: [
{ entry: "src/main.ts", config: "vite.main.config.ts", target: "main" },
{ entry: "src/preload.ts", config: "vite.preload.config.ts", target: "preload" },
],
renderer: [{ name: "main_window", config: "vite.renderer.config.ts" }],
}),
],
};
export default config;

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Orchestrator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/renderer/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -3,14 +3,70 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"description": "Electron + TypeScript frontend for the agent-orchestrator rewrite", "description": "Electron + TypeScript frontend for the agent-orchestrator rewrite",
"main": "dist/main.js", "main": ".vite/build/main.js",
"repository": {
"type": "git",
"url": "https://github.com/aoagents/agent-orchestrator"
},
"scripts": { "scripts": {
"build": "tsc", "dev": "electron-forge start",
"dev:web": "VITE_NO_ELECTRON=1 vite --config vite.renderer.config.ts",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"start": "npm run build && electron ." "test": "vitest run",
"test:e2e": "playwright test",
"api:ts": "openapi-typescript ../backend/internal/httpd/apispec/openapi.yaml -o src/api/schema.ts"
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.8.0",
"@electron-forge/maker-deb": "^7.8.0",
"@electron-forge/maker-rpm": "^7.8.0",
"@electron-forge/maker-squirrel": "^7.8.0",
"@electron-forge/maker-zip": "^7.8.0",
"@electron-forge/plugin-vite": "^7.8.0",
"@electron-forge/publisher-github": "^7.8.0",
"@playwright/test": "^1.60.0",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/router-plugin": "^1.168.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"electron": "^33.0.0", "electron": "^33.0.0",
"typescript": "^5.6.0" "jsdom": "^29.1.1",
"openapi-typescript": "^7.13.0",
"playwright": "^1.60.0",
"tailwindcss": "^4.3.0",
"typescript": "^5.6.0",
"vite": "^8.0.16",
"vitest": "^4.1.8"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.9",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.15",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"openapi-fetch": "^0.17.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-resizable-panels": "^4.11.2",
"tailwind-merge": "^3.6.0",
"update-electron-app": "^3.0.0",
"zustand": "^5.0.14"
} }
} }

View File

@ -0,0 +1,15 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "e2e",
use: {
baseURL: "http://127.0.0.1:5173",
},
webServer: {
// dev:web serves the renderer alone (VITE_NO_ELECTRON=1) — no Electron child to
// launch, which is all the browser-based e2e suite needs.
command: "npm run dev:web -- --port 5173",
port: 5173,
reuseExistingServer: !process.env.CI,
},
});

8089
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -422,6 +422,7 @@ export interface components {
ProjectSummary: { ProjectSummary: {
id: string; id: string;
name: string; name: string;
path: string;
resolveError?: string; resolveError?: string;
sessionPrefix: string; sessionPrefix: string;
}; };
@ -476,6 +477,7 @@ export interface components {
kind: string; kind: string;
projectId: string; projectId: string;
status: string; status: string;
terminalHandleId?: string;
/** Format: date-time */ /** Format: date-time */
updatedAt: string; updatedAt: string;
}; };

View File

@ -1,20 +1,314 @@
import { app, BrowserWindow } from "electron"; import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell, type OpenDialogOptions } from "electron";
import updateElectronApp from "update-electron-app";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createListenPortScanner, defaultRunFilePath, parseRunFile } from "./shared/daemon-discovery";
import type { DaemonStatus } from "./shared/daemon-status";
// Globals injected at compile time by @electron-forge/plugin-vite.
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
declare const MAIN_WINDOW_VITE_NAME: string;
let mainWindow: BrowserWindow | null = null;
let daemonProcess: ChildProcessWithoutNullStreams | null = null;
let daemonStatus: DaemonStatus = { state: "stopped" };
const isDev = !app.isPackaged;
const RENDERER_SCHEME = "app";
const RENDERER_HOST = "renderer";
const RENDERER_ORIGIN = `${RENDERER_SCHEME}://${RENDERER_HOST}`;
// The packaged renderer is served from a custom standard scheme, not file://.
// A file:// page has the opaque "null" origin, which the daemon must never
// trust (every sandboxed iframe on any website also presents "null"), so its
// fetch/EventSource calls to the loopback API would be CORS-blocked.
// app://renderer is an origin only this app can present, so the daemon's CORS
// allowlist can name it. A standard scheme also makes the build's absolute
// asset URLs (/assets/…) and history-API routing resolve, which file:// breaks.
// Must run before app ready.
protocol.registerSchemesAsPrivileged([
{
scheme: RENDERER_SCHEME,
privileges: { standard: true, secure: true, supportFetchAPI: true },
},
]);
// Maps app://renderer/<path> to the built renderer in dist/. Paths without a
// file extension are client-side routes and fall back to index.html (SPA).
function registerRendererProtocol(): void {
const distRoot = path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}`);
protocol.handle(RENDERER_SCHEME, async (request) => {
const url = new URL(request.url);
if (url.host !== RENDERER_HOST) {
return new Response("Not found", { status: 404 });
}
const resolved = path.resolve(path.join(distRoot, decodeURIComponent(url.pathname)));
if (resolved !== distRoot && !resolved.startsWith(distRoot + path.sep)) {
return new Response("Forbidden", { status: 403 });
}
const target = path.extname(resolved) === "" ? path.join(distRoot, "index.html") : resolved;
try {
return await net.fetch(pathToFileURL(target).toString());
} catch {
return new Response("Not found", { status: 404 });
}
});
}
function rendererUrl(): string {
if (typeof MAIN_WINDOW_VITE_DEV_SERVER_URL !== "undefined" && MAIN_WINDOW_VITE_DEV_SERVER_URL) {
return MAIN_WINDOW_VITE_DEV_SERVER_URL;
}
return `${RENDERER_ORIGIN}/index.html`;
}
function preloadPath(): string {
return path.join(__dirname, "preload.js");
}
function setDaemonStatus(nextStatus: DaemonStatus): void {
daemonStatus = nextStatus;
mainWindow?.webContents.send("daemon:status", daemonStatus);
}
function createWindow(): void { function createWindow(): void {
const window = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1200, width: 1320,
height: 800, height: 860,
minWidth: 960,
minHeight: 640,
title: "Agent Orchestrator",
backgroundColor: "#0f1014",
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 14, y: 14 },
webPreferences: { webPreferences: {
preload: preloadPath(),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
sandbox: true,
}, },
}); });
void window.loadURL("about:blank"); // Harden navigation: never let renderer/terminal content open in-app windows or
// navigate the privileged window away from the app origin. External links go to
// the OS browser. Keep this in place before exposing any daemon output to the renderer.
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//.test(url)) {
void shell.openExternal(url);
}
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (event, url) => {
if (url !== mainWindow?.webContents.getURL()) {
event.preventDefault();
}
});
void mainWindow.loadURL(rendererUrl());
if (isDev && process.env.AO_OPEN_DEVTOOLS === "1") {
mainWindow.webContents.once("did-frame-finish-load", () => {
mainWindow?.webContents.openDevTools({ mode: "detach" });
});
}
mainWindow.on("closed", () => {
mainWindow = null;
});
}
// How long the supervisor waits for the daemon to confirm its bound port (via
// the listen log line or running.json) before reporting the configured port as
// a best-effort fallback.
const PORT_DISCOVERY_TIMEOUT_MS = 15_000;
const RUN_FILE_POLL_MS = 300;
// Accept run-files stamped slightly before our spawn timestamp: the daemon's
// clock reading and ours race within normal scheduling jitter.
const RUN_FILE_FRESHNESS_SKEW_MS = 2_000;
function runFilePath(): string | null {
if (process.env.AO_RUN_FILE) return process.env.AO_RUN_FILE;
return defaultRunFilePath(process.platform, process.env, os.homedir());
}
function startDaemon(): DaemonStatus {
if (daemonProcess) {
return daemonStatus;
}
const command = process.env.AO_DAEMON_COMMAND;
if (!command) {
setDaemonStatus({
state: "stopped",
message: "AO_DAEMON_COMMAND is not configured; renderer uses loopback REST when available.",
});
return daemonStatus;
}
setDaemonStatus({ state: "starting" });
// Capture the spawned handle locally so the async lifecycle listeners act only
// on THIS process. Without this, a stale exit from an already-stopped daemon
// could null out a newer daemonProcess started in the meantime, orphaning it.
//
// `detached` makes the child its own process-group leader. Because shell:true
// runs the command through /bin/sh, a plain kill() would only signal the shell
// wrapper and orphan the real daemon (which keeps holding the port). Killing
// the whole group via killDaemon() reaches the daemon and any PTY children.
const child = spawn(command, [], {
cwd: app.getAppPath(),
env: process.env,
shell: true,
detached: true,
});
daemonProcess = child;
// Discover the port the daemon ACTUALLY bound rather than trusting AO_PORT:
// the daemon may fall back to a different port than the one requested. Two
// confirmed sources race — the "daemon listening" slog line (stderr, but both
// streams are scanned) and the running.json handshake — first one wins.
const spawnedAtMs = Date.now();
let portConfirmed = false;
let runFileTimer: ReturnType<typeof setInterval> | undefined;
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
const stopDiscovery = () => {
if (runFileTimer) clearInterval(runFileTimer);
runFileTimer = undefined;
if (fallbackTimer) clearTimeout(fallbackTimer);
fallbackTimer = undefined;
};
const reportBoundPort = (port: number) => {
if (portConfirmed || daemonProcess !== child) return;
portConfirmed = true;
stopDiscovery();
setDaemonStatus({ state: "ready", port });
};
// One scanner per stream: each keeps its own partial-line buffer.
const scanStdout = createListenPortScanner(reportBoundPort);
const scanStderr = createListenPortScanner(reportBoundPort);
child.stdout.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8");
console.log(text.trimEnd());
scanStdout(text);
});
child.stderr.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8");
console.error(text.trimEnd());
scanStderr(text);
});
const handshakePath = runFilePath();
if (handshakePath) {
runFileTimer = setInterval(() => {
readFile(handshakePath, "utf8")
.then((contents) => {
const info = parseRunFile(contents);
// Ignore a stale handshake left by a previous daemon: only trust a
// file written at/after this spawn.
if (info && info.startedAtMs >= spawnedAtMs - RUN_FILE_FRESHNESS_SKEW_MS) {
reportBoundPort(info.port);
}
})
.catch(() => undefined); // absent until the daemon binds; keep polling
}, RUN_FILE_POLL_MS);
}
// Last resort: neither source confirmed (e.g. an older daemon build). Report
// the configured port so the renderer is not stuck on "starting" forever.
fallbackTimer = setTimeout(() => {
if (portConfirmed || daemonProcess !== child) return;
stopDiscovery();
setDaemonStatus({
state: "ready",
port: process.env.AO_PORT ? Number(process.env.AO_PORT) : undefined,
message: "Daemon port not confirmed from logs or running.json; assuming the configured port.",
});
}, PORT_DISCOVERY_TIMEOUT_MS);
child.once("error", (error) => {
stopDiscovery();
if (daemonProcess !== child) return;
daemonProcess = null;
setDaemonStatus({ state: "error", message: error.message });
});
child.once("exit", (code, signal) => {
stopDiscovery();
if (daemonProcess !== child) return;
daemonProcess = null;
setDaemonStatus({
state: "stopped",
message: signal ? `Daemon exited with ${signal}` : `Daemon exited with code ${code ?? "unknown"}`,
});
});
return daemonStatus;
}
// Signal the daemon's whole process group so the kill reaches the real daemon
// behind the /bin/sh wrapper (and any PTY children it forked), not just the
// shell. Falls back to a direct kill if the group signal can't be delivered
// (e.g. the process already exited).
function killDaemon(child: ChildProcessWithoutNullStreams): void {
if (child.pid === undefined) return;
try {
process.kill(-child.pid, "SIGTERM");
} catch {
child.kill("SIGTERM");
}
}
function stopDaemon(): DaemonStatus {
if (!daemonProcess) {
setDaemonStatus({ state: "stopped" });
return daemonStatus;
}
killDaemon(daemonProcess);
daemonProcess = null;
setDaemonStatus({ state: "stopped" });
return daemonStatus;
}
ipcMain.handle("daemon:getStatus", () => daemonStatus);
ipcMain.handle("daemon:start", () => startDaemon());
ipcMain.handle("daemon:stop", () => stopDaemon());
ipcMain.handle("app:getVersion", () => app.getVersion());
ipcMain.handle("app:chooseDirectory", async () => {
const options: OpenDialogOptions = {
properties: ["openDirectory"],
title: "Choose a git repository",
};
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
if (result.canceled) return null;
return result.filePaths[0] ?? null;
});
// Auto-update only runs for packaged builds reading the GitHub Releases feed
// (see forge.config.ts publishers). In dev there is no feed, so it is skipped.
// A live updater additionally requires a signed + notarized build — see
// frontend/docs/desktop-release.md.
function initAutoUpdates(): void {
if (!app.isPackaged) return;
updateElectronApp();
} }
app.whenReady().then(() => { app.whenReady().then(() => {
registerRendererProtocol();
createWindow(); createWindow();
initAutoUpdates();
app.on("activate", () => { app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
@ -23,6 +317,12 @@ app.whenReady().then(() => {
}); });
}); });
app.on("before-quit", () => {
if (daemonProcess) {
killDaemon(daemonProcess);
}
});
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
app.quit(); app.quit();

25
frontend/src/preload.ts Normal file
View File

@ -0,0 +1,25 @@
import { contextBridge, ipcRenderer } from "electron";
import type { DaemonStatus } from "./shared/daemon-status";
const api = {
app: {
getVersion: () => ipcRenderer.invoke("app:getVersion") as Promise<string>,
chooseDirectory: () => ipcRenderer.invoke("app:chooseDirectory") as Promise<string | null>,
},
daemon: {
getStatus: () => ipcRenderer.invoke("daemon:getStatus") as Promise<DaemonStatus>,
start: () => ipcRenderer.invoke("daemon:start") as Promise<DaemonStatus>,
stop: () => ipcRenderer.invoke("daemon:stop") as Promise<DaemonStatus>,
onStatus: (listener: (status: DaemonStatus) => void) => {
const wrapped = (_event: Electron.IpcRendererEvent, status: DaemonStatus) => listener(status);
ipcRenderer.on("daemon:status", wrapped);
return () => {
ipcRenderer.off("daemon:status", wrapped);
};
},
},
};
contextBridge.exposeInMainWorld("ao", api);
export type AoBridge = typeof api;

View File

@ -0,0 +1,195 @@
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { beforeEach, expect, test, vi } from "vitest";
import { App } from "./App";
import { useUiStore } from "./stores/ui-store";
const { postMock, mockData } = vi.hoisted(() => ({
postMock: vi.fn(),
mockData: {
projectsError: undefined as Error | undefined,
projects: [] as { id: string; name: string; path: string; sessionPrefix: string }[],
sessions: [] as {
id: string;
projectId: string;
displayName?: string;
harness?: string;
status: string;
isTerminated: boolean;
updatedAt: string;
}[],
},
}));
vi.mock("./lib/api-client", () => ({
getApiBaseUrl: () => "http://127.0.0.1:3001",
setApiBaseUrl: () => undefined,
subscribeApiBaseUrl: () => () => undefined,
apiClient: {
GET: vi.fn(async (url: string) => {
if (url === "/api/v1/projects") {
if (mockData.projectsError) return { data: undefined, error: mockData.projectsError };
return { data: { projects: mockData.projects }, error: undefined };
}
if (url === "/api/v1/sessions") {
return { data: { sessions: mockData.sessions }, error: undefined };
}
return { data: undefined, error: new Error(`unexpected GET ${url}`) };
}),
POST: postMock,
},
}));
vi.mock("./components/TerminalPane", () => ({
TerminalPane: () => <div>Terminal scaffold</div>,
}));
function renderApp() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);
}
beforeEach(() => {
postMock.mockReset();
mockData.projectsError = undefined;
mockData.projects = [];
mockData.sessions = [];
window.localStorage.clear();
useUiStore.setState({
view: "orchestrator",
workbenchTab: "changes",
isSidebarOpen: true,
selectedSessionId: null,
selectedWorkspaceId: null,
theme: "dark",
});
});
test("renders the orchestrator anchor and empty state", async () => {
renderApp();
expect(await screen.findByRole("button", { name: "Orchestrator" })).toBeInTheDocument();
expect(await screen.findByText("No projects yet.")).toBeInTheDocument();
});
test("surfaces project load failures instead of the empty state", async () => {
mockData.projectsError = new TypeError("Failed to fetch");
renderApp();
expect(await screen.findByText("Could not load projects.", undefined, { timeout: 3_000 })).toBeInTheDocument();
expect(await screen.findByText("Failed to fetch")).toBeInTheDocument();
expect(screen.queryByText("No projects yet.")).not.toBeInTheDocument();
});
test("renders projects and sessions from the API", async () => {
mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }];
mockData.sessions = [
{
id: "sess-1",
projectId: "proj-1",
displayName: "fix-bug",
harness: "claude-code",
status: "working",
isTerminated: false,
updatedAt: new Date().toISOString(),
},
];
renderApp();
expect(await screen.findByRole("button", { name: "Select my-app" })).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "fix-bug" })).toBeInTheDocument();
});
test("adds a project from the rail", async () => {
const user = userEvent.setup();
const bridge = window.ao;
if (!bridge) throw new Error("test preload bridge is not installed");
bridge.app.chooseDirectory = vi.fn(async () => "/Users/me/new-project");
postMock.mockResolvedValueOnce({
data: {
project: {
id: "new-project",
name: "New Project",
path: "/Users/me/new-project",
repo: "git@example.com:new-project.git",
defaultBranch: "main",
},
},
});
renderApp();
await user.click(await screen.findByRole("button", { name: "New project" }));
expect(bridge.app.chooseDirectory).toHaveBeenCalled();
expect(postMock).toHaveBeenCalledWith("/api/v1/projects", { body: { path: "/Users/me/new-project" } });
expect(await screen.findByText("New Project")).toBeInTheDocument();
});
test("spawns a worker from the New worker modal", async () => {
const user = userEvent.setup();
mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }];
postMock.mockResolvedValueOnce({
data: {
session: {
id: "new-task",
projectId: "proj-1",
harness: "claude-code",
branch: "main",
isTerminated: false,
},
},
});
renderApp();
// Wait for projects to load.
await screen.findByRole("button", { name: "Select my-app" });
// Open spawn modal from the orchestrator topbar.
await user.click(screen.getByRole("button", { name: "New worker" }));
await user.type(await screen.findByLabelText("Prompt"), "Make task creation work");
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", {
body: {
projectId: "proj-1",
kind: "worker",
harness: "claude-code",
prompt: "Make task creation work",
branch: "main",
},
});
});
test("surfaces an error when spawning fails", async () => {
const user = userEvent.setup();
mockData.projects = [{ id: "proj-1", name: "my-app", path: "/home/me/my-app", sessionPrefix: "" }];
postMock.mockResolvedValueOnce({ error: new TypeError("Failed to fetch") });
renderApp();
await screen.findByRole("button", { name: "Select my-app" });
await user.click(screen.getByRole("button", { name: "New worker" }));
await user.type(await screen.findByLabelText("Prompt"), "Failing task");
await user.click(screen.getByRole("button", { name: /Spawn worker/ }));
expect(postMock).toHaveBeenCalledWith("/api/v1/sessions", {
body: {
projectId: "proj-1",
kind: "worker",
harness: "claude-code",
prompt: "Failing task",
branch: "main",
},
});
expect(await screen.findByText("Failed to fetch")).toBeInTheDocument();
});

View File

@ -0,0 +1,209 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { CenterPane } from "./components/CenterPane";
import { SideRail } from "./components/SideRail";
import { Sidebar } from "./components/Sidebar";
import { SpawnWorkerModal } from "./components/SpawnWorkerModal";
import { Topbar } from "./components/Topbar";
import { useDaemonStatus } from "./hooks/useDaemonStatus";
import { useWorkspaceQuery, workspaceQueryKey } from "./hooks/useWorkspaceQuery";
import { apiClient } from "./lib/api-client";
import { Theme, useUiStore } from "./stores/ui-store";
import { toAgentProvider, toSessionStatus, type AgentProvider, type WorkspaceSummary } from "./types/workspace";
type AppProps = {
routeSessionId?: string;
routeWorkspaceId?: string;
};
function systemTheme(): Theme {
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Could not load projects";
}
export function App({ routeSessionId, routeWorkspaceId }: AppProps) {
const queryClient = useQueryClient();
const {
view,
workbenchTab,
selectedSessionId,
selectedWorkspaceId,
theme,
setSystemTheme,
setWorkbenchTab,
toggleSidebar,
selectWorkspace,
selectSession,
} = useUiStore();
const workspaceQuery = useWorkspaceQuery();
const workspaces = workspaceQuery.data ?? [];
const daemonStatus = useDaemonStatus(queryClient);
const [spawnOpen, setSpawnOpen] = useState(false);
const [spawnProjectId, setSpawnProjectId] = useState<string | undefined>(undefined);
const openSpawn = (projectId?: string) => {
setSpawnProjectId(projectId);
setSpawnOpen(true);
};
const selectedWorkspace =
workspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? workspaces[0] ?? null;
const selectedSession =
view === "session"
? workspaces.flatMap((workspace) => workspace.sessions).find((session) => session.id === selectedSessionId)
: undefined;
const sessionWorkspace = selectedSession
? (workspaces.find((workspace) => workspace.id === selectedSession.workspaceId) ?? selectedWorkspace)
: selectedWorkspace;
useEffect(() => {
if (routeWorkspaceId) selectWorkspace(routeWorkspaceId);
if (routeSessionId) selectSession(routeSessionId, routeWorkspaceId);
}, [routeSessionId, routeWorkspaceId, selectWorkspace, selectSession]);
useEffect(() => {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}, [theme]);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: light)");
const handleChange = () => setSystemTheme(systemTheme());
handleChange();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [setSystemTheme]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "b") {
event.preventDefault();
toggleSidebar();
return;
}
if ((event.metaKey || event.ctrlKey) && /^[1-9]$/.test(event.key)) {
const workspace = workspaces[Number(event.key) - 1];
if (workspace) {
event.preventDefault();
selectWorkspace(workspace.id);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectWorkspace, toggleSidebar, workspaces]);
const updateWorkspaces = (updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => {
queryClient.setQueryData<WorkspaceSummary[]>(workspaceQueryKey, (current = workspaces) => updater(current));
};
const createProject = async (input: { path: string }) => {
const { data, error } = await apiClient.POST("/api/v1/projects", { body: { path: input.path } });
if (error) throw error;
if (!data?.project) throw new Error("Project creation returned no project");
const workspace: WorkspaceSummary = {
id: data.project.id,
name: data.project.name,
path: data.project.path,
type: "main",
sessions: [],
};
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
selectWorkspace(workspace.id);
};
const createTask = async (input: { projectId: string; prompt: string; branch?: string; harness?: AgentProvider }) => {
const { data, error } = await apiClient.POST("/api/v1/sessions", {
body: {
projectId: input.projectId,
kind: "worker",
harness: input.harness,
prompt: input.prompt,
branch: input.branch || undefined,
},
});
if (error || !data?.session) {
throw new Error(error instanceof Error ? error.message : error ? String(error) : "No session returned");
}
const session = data.session;
updateWorkspaces((current) =>
current.map((item) =>
item.id === input.projectId
? {
...item,
sessions: [
{
id: session.id,
terminalHandleId: session.terminalHandleId,
workspaceId: item.id,
workspaceName: item.name,
title: input.prompt,
provider: toAgentProvider(session.harness),
branch: input.branch ?? "",
status: toSessionStatus(session.status, session.isTerminated),
updatedAt: "now",
},
...item.sessions.filter((existing) => existing.id !== session.id),
],
}
: item,
),
);
selectSession(session.id, input.projectId);
};
const showSideRail = !(view === "session" && workbenchTab === "terminal");
return (
<>
<div className="flex h-screen flex-col bg-background text-foreground">
<Topbar
onNewWorker={() => openSpawn()}
onSetWorkbenchTab={setWorkbenchTab}
onToggleSidebar={toggleSidebar}
session={selectedSession}
view={view}
workbenchTab={workbenchTab}
workspace={sessionWorkspace}
/>
<div className="flex min-h-0 flex-1">
<Sidebar
daemonStatus={daemonStatus}
onCreateProject={createProject}
onNewWorker={openSpawn}
workspaceError={workspaceQuery.isError ? errorMessage(workspaceQuery.error) : undefined}
workspaces={workspaces}
/>
<CenterPane
daemonReady={daemonStatus.state === "ready"}
session={selectedSession}
theme={theme}
view={view}
/>
{showSideRail && (
<SideRail onSelectSession={selectSession} session={selectedSession} view={view} workspaces={workspaces} />
)}
</div>
</div>
<SpawnWorkerModal
defaultProjectId={spawnProjectId ?? selectedWorkspace?.id}
onCreateTask={createTask}
onOpenChange={setSpawnOpen}
open={spawnOpen}
workspaces={workspaces}
/>
</>
);
}

View File

@ -0,0 +1,48 @@
import { Columns2 } from "lucide-react";
import type { Theme, WorkbenchView } from "../stores/ui-store";
import type { WorkspaceSession } from "../types/workspace";
import { TerminalPane } from "./TerminalPane";
type CenterPaneProps = {
view: WorkbenchView;
session?: WorkspaceSession;
theme: Theme;
daemonReady: boolean;
};
export function CenterPane({ view, session, theme, daemonReady }: CenterPaneProps) {
const isOrchestrator = view === "orchestrator";
const agentLabel = session?.provider ?? "claude-code";
return (
<div className="flex min-w-0 flex-1 flex-col bg-background">
<div className="flex h-[38px] shrink-0 items-center border-b border-border px-2.5">
<div className="-mb-px flex h-[38px] items-center gap-2 border-b-2 border-accent px-3 text-[13px] text-foreground">
<span className="h-[7px] w-[7px] rounded-full bg-success shadow-[0_0_0_3px_rgb(108_177_108_/_0.24)]" />
{isOrchestrator ? (
<>
orchestrator <span className="font-mono text-[11px] text-passive">{agentLabel}</span>
</>
) : (
<>
{agentLabel} <span className="font-mono text-[11px] text-passive">(1)</span>
</>
)}
</div>
{!isOrchestrator && (
<button
aria-label="Split terminal"
className="ml-auto grid h-7 w-7 place-items-center rounded-md text-passive transition-colors hover:bg-raised hover:text-muted-foreground"
type="button"
>
<Columns2 className="h-[15px] w-[15px]" aria-hidden="true" />
</button>
)}
</div>
<div className="min-h-0 flex-1">
<TerminalPane session={session} theme={theme} daemonReady={daemonReady} />
</div>
</div>
);
}

View File

@ -0,0 +1,206 @@
import { GitBranch, GitCommitHorizontal, GitPullRequest, LoaderCircle, Plus, Square, Trash2 } from "lucide-react";
import type { WorkbenchView } from "../stores/ui-store";
import {
type WorkerDisplayStatus,
type WorkspaceSession,
type WorkspaceSummary,
workerDisplayStatus,
workerStatusLabel,
workerStatusPulses,
} from "../types/workspace";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
// Session status is a single glyph, no text: spinner while working, a PR icon
// when there's a PR, otherwise a colored dot. See DESIGN.md.
const dotTone: Record<WorkerDisplayStatus, string> = {
working: "bg-accent",
needs_you: "bg-warning",
mergeable: "bg-success",
ci_failed: "bg-error",
done: "bg-passive",
};
const prTone: Record<WorkerDisplayStatus, string> = {
working: "text-accent",
needs_you: "text-warning",
mergeable: "text-success",
ci_failed: "text-error",
done: "text-muted-foreground",
};
function StatusGlyph({ worker }: { worker: WorkspaceSession }) {
const status = workerDisplayStatus(worker);
let glyph: React.ReactNode;
if (status === "working") {
glyph = <LoaderCircle className="h-3.5 w-3.5 animate-spin text-accent" aria-hidden="true" />;
} else if (worker.pullRequest) {
glyph = <GitPullRequest className={cn("h-3.5 w-3.5", prTone[status])} aria-hidden="true" />;
} else {
glyph = (
<span
className={cn(
"h-[7px] w-[7px] rounded-full",
dotTone[status],
workerStatusPulses(status) && "animate-status-pulse",
)}
/>
);
}
return (
<span className="grid h-3.5 w-3.5 shrink-0 place-items-center" title={workerStatusLabel[status]}>
{glyph}
</span>
);
}
type SideRailProps = {
view: WorkbenchView;
session?: WorkspaceSession;
workspaces: WorkspaceSummary[];
onSelectSession: (sessionId: string, workspaceId: string) => void;
};
export function SideRail({ view, session, workspaces, onSelectSession }: SideRailProps) {
return (
<aside className="flex h-full w-[316px] shrink-0 flex-col border-l border-border bg-background">
{view === "orchestrator" ? (
<WorkersList workspaces={workspaces} onSelectSession={onSelectSession} />
) : (
<GitRail session={session} />
)}
</aside>
);
}
function WorkersList({
workspaces,
onSelectSession,
}: {
workspaces: WorkspaceSummary[];
onSelectSession: (sessionId: string, workspaceId: string) => void;
}) {
const workers = workspaces.flatMap((workspace) => workspace.sessions);
return (
<>
<SideHead title="Workers" count={workers.length} />
<div className="min-h-0 flex-1 overflow-y-auto">
{workers.length === 0 ? (
<p className="px-3 py-6 text-center text-[12px] text-passive">No workers yet.</p>
) : (
workers.map((worker) => {
const pr = worker.pullRequest;
const subtitle = [worker.workspaceName, pr ? `PR #${pr.number}` : worker.branch]
.filter(Boolean)
.join(" · ");
return (
<button
className="flex h-10 w-full items-center gap-2.5 border-b border-border/50 px-3 text-left transition-colors hover:bg-surface"
key={worker.id}
onClick={() => onSelectSession(worker.id, worker.workspaceId)}
type="button"
>
<StatusGlyph worker={worker} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] text-foreground">{worker.title}</span>
<span className="block truncate font-mono text-[10px] text-passive">{subtitle}</span>
</span>
</button>
);
})
)}
</div>
</>
);
}
function GitRail({ session }: { session?: WorkspaceSession }) {
const files = session?.changedFiles ?? [];
return (
<>
<SideHead title="Changed" count={files.length} />
<div className="flex items-center gap-3 border-b border-border px-3 py-2 text-[12px]">
<button className="text-muted-foreground transition-colors hover:text-foreground" type="button">
All files
</button>
<button
className="inline-flex items-center gap-1.5 text-error transition-colors hover:opacity-80"
type="button"
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
Discard all
</button>
<button
className="ml-auto inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
type="button"
>
<Plus className="h-3 w-3" aria-hidden="true" />
Stage all
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{files.length === 0 ? (
<p className="px-2 py-6 text-center text-[12px] text-passive">No changes yet.</p>
) : (
files.map((file) => (
<div
className="flex h-7 items-center gap-2 rounded-md px-2 font-mono text-[12px] text-muted-foreground transition-colors hover:bg-surface"
key={file.path}
>
<span className="min-w-0 flex-1 truncate text-foreground">{file.path}</span>
<span className="shrink-0 text-success">+{file.additions}</span>
<span className="shrink-0 text-error">{file.deletions}</span>
<Square
className={cn("h-[13px] w-[13px] shrink-0", file.staged ? "text-accent" : "text-passive")}
aria-hidden="true"
/>
</div>
))
)}
</div>
<div className="flex flex-col gap-2 border-t border-border p-3">
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-[12.5px] text-foreground placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak"
defaultValue={session?.commitMessage ?? ""}
key={session?.id}
placeholder="Commit message"
/>
<textarea
className="w-full resize-none rounded-md border border-border bg-transparent px-2.5 py-1.5 text-[12.5px] text-foreground placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak"
placeholder="Description"
rows={2}
/>
<Button className="w-full" disabled={files.length === 0} variant="primary">
<GitCommitHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
Commit &amp; Push
</Button>
</div>
<div className="flex items-center gap-2.5 border-t border-border px-3 py-2 font-mono text-[11px] text-passive">
<GitBranch className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate text-muted-foreground">{session?.branch || "—"}</span>
<button
className="ml-auto inline-flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-0.5 text-muted-foreground transition-colors hover:bg-surface"
type="button"
>
<Plus className="h-3 w-3" aria-hidden="true" />
<GitPullRequest className="h-3 w-3" aria-hidden="true" />
Create PR
</button>
</div>
</>
);
}
function SideHead({ title, count }: { title: string; count: number }) {
return (
<div className="flex h-[38px] shrink-0 items-center gap-2 border-b border-border px-3">
<span className="text-[13px] font-semibold text-foreground">{title}</span>
<span className="font-mono text-[11px] text-passive">{count}</span>
</div>
);
}

View File

@ -0,0 +1,310 @@
import { ChevronsUpDown, Folder, Plus, Search, Settings, Waypoints } from "lucide-react";
import { useState } from "react";
import { sessionIsActive, sessionNeedsAttention, type WorkspaceSummary } from "../types/workspace";
import { useUiStore } from "../stores/ui-store";
import { aoBridge } from "../lib/bridge";
import { useEventsConnection } from "../hooks/useEventsConnection";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { cn } from "../lib/utils";
type SidebarProps = {
daemonStatus: { state: string; message?: string };
workspaceError?: string;
workspaces: WorkspaceSummary[];
onCreateProject: (input: { path: string }) => Promise<void>;
onNewWorker: (projectId: string) => void;
};
function fleetSummary(workspaces: WorkspaceSummary[]) {
const sessions = workspaces.flatMap((workspace) => workspace.sessions);
const agents = sessions.filter(sessionIsActive).length;
const needYou = sessions.filter(sessionNeedsAttention).length;
return { agents, needYou };
}
export function Sidebar({ daemonStatus, workspaceError, workspaces, onCreateProject, onNewWorker }: SidebarProps) {
const {
isSidebarOpen,
view,
selectedSessionId,
selectedWorkspaceId,
selectOrchestrator,
selectSession,
selectWorkspace,
} = useUiStore();
const { agents, needYou } = fleetSummary(workspaces);
const eventsConnection = useEventsConnection();
if (!isSidebarOpen) {
return (
<CollapsedRail agents={agents} needYou={needYou} onCreateProject={onCreateProject} workspaces={workspaces} />
);
}
return (
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-border bg-sidebar text-sidebar-foreground">
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{/* Orchestrator anchor — ReverbCode's one addition over emdash. */}
<button
aria-label="Orchestrator"
className={cn(
"group relative mb-2 flex h-[38px] w-full items-center gap-2.5 rounded-lg pl-3 pr-2.5 text-left transition-colors",
"before:absolute before:left-0 before:top-2 before:bottom-2 before:w-0.5 before:rounded-r before:bg-accent",
view === "orchestrator" ? "bg-overlay" : "bg-raised hover:bg-overlay",
)}
onClick={selectOrchestrator}
type="button"
>
<Waypoints className="h-4 w-4 shrink-0 text-accent" aria-hidden="true" />
<span className="text-[13.5px] font-semibold text-foreground">Orchestrator</span>
<span className="ml-auto whitespace-nowrap font-mono text-[10px] text-passive">{agents} agents</span>
</button>
<div className="flex h-[34px] items-center gap-1.5 px-2">
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-passive">Projects</span>
<CreateProjectButton onCreateProject={onCreateProject} />
</div>
{workspaceError ? (
<div className="px-3 py-4">
<p className="text-[12px] text-foreground">Could not load projects.</p>
<p className="mt-1 text-[11px] text-passive">{workspaceError}</p>
</div>
) : workspaces.length === 0 ? (
<div className="px-3 py-4">
<p className="text-[12px] text-passive">No projects yet.</p>
<p className="mt-1 text-[11px] text-passive">
Click <span className="text-foreground">+</span> above to register a git repo.
</p>
</div>
) : (
workspaces.map((workspace) => (
<section key={workspace.id} className="mb-1">
<div
className={cn(
"group flex h-8 w-full items-center rounded-lg pr-1 transition-colors",
selectedWorkspaceId === workspace.id && view !== "session"
? "bg-raised text-foreground"
: "text-muted-foreground hover:bg-surface",
)}
>
<button
aria-label={`Select ${workspace.name}`}
className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left"
onClick={() => selectWorkspace(workspace.id)}
type="button"
>
<Folder className="h-3.5 w-3.5 shrink-0 text-passive" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-[13.5px]">{workspace.name}</span>
</button>
<NewWorkerButton
onClick={() => onNewWorker(workspace.id)}
projectName={workspace.name}
visible={selectedWorkspaceId === workspace.id && view !== "session"}
/>
</div>
{workspace.sessions.map((session) => {
const active = view === "session" && selectedSessionId === session.id;
return (
<button
aria-label={session.title}
className={cn(
"relative flex h-8 w-full items-center rounded-lg pl-[30px] pr-2 text-left transition-colors",
active
? "bg-raised text-foreground before:absolute before:left-5 before:top-2 before:bottom-2 before:w-0.5 before:rounded before:bg-accent"
: "text-muted-foreground hover:bg-surface",
)}
key={session.id}
onClick={() => selectSession(session.id, workspace.id)}
type="button"
>
<span className="min-w-0 flex-1 truncate text-[13.5px]">{session.title}</span>
</button>
);
})}
</section>
))
)}
</div>
<div className="border-t border-border p-2">
<FooterRow icon={<Search className="h-[15px] w-[15px]" aria-hidden="true" />} label="Search" shortcut="⌘K" />
<FooterRow
icon={<Settings className="h-[15px] w-[15px]" aria-hidden="true" />}
label="Settings"
shortcut="⌘,"
/>
</div>
<div className="flex items-center gap-2.5 border-t border-border px-2.5 py-2">
<span className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-md bg-accent text-[11px] font-semibold text-accent-foreground">
R
</span>
<div className="min-w-0">
<div className="truncate text-[12.5px] text-foreground">ReverbCode</div>
<div className="truncate font-mono text-[10px] text-passive">
daemon {daemonStatus.state}
{eventsConnection === "disconnected" && " · events offline"}
</div>
</div>
<ChevronsUpDown className="ml-auto h-3.5 w-3.5 shrink-0 text-passive" aria-hidden="true" />
</div>
</aside>
);
}
function FooterRow({ icon, label, shortcut }: { icon: React.ReactNode; label: string; shortcut: string }) {
return (
<button
className="flex h-7 w-full items-center gap-2.5 rounded-lg px-2 text-left text-[13px] text-muted-foreground transition-colors hover:bg-surface [&_svg]:text-passive"
type="button"
>
{icon}
<span className="min-w-0 flex-1 truncate">{label}</span>
<span className="font-mono text-[10px] text-passive">{shortcut}</span>
</button>
);
}
function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
const [error, setError] = useState<string | null>(null);
const [isChoosingPath, setIsChoosingPath] = useState(false);
const choosePath = async () => {
setError(null);
setIsChoosingPath(true);
try {
const selectedPath = await aoBridge.app.chooseDirectory();
if (selectedPath) await onCreateProject({ path: selectedPath });
} catch (err) {
setError(err instanceof Error ? err.message : "Could not add project");
} finally {
setIsChoosingPath(false);
}
};
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="New project"
className="ml-auto grid h-6 w-6 place-items-center rounded-md text-passive transition-colors hover:bg-surface hover:text-foreground"
disabled={isChoosingPath}
onClick={choosePath}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{isChoosingPath ? "Opening…" : "New project"}</TooltipContent>
</Tooltip>
{error && (
<span className="sr-only" role="status">
{error}
</span>
)}
</>
);
}
function NewWorkerButton({
onClick,
projectName,
visible,
}: {
onClick: () => void;
projectName: string;
visible: boolean;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`New worker in ${projectName}`}
className={cn(
"grid h-6 w-6 shrink-0 place-items-center rounded-md text-passive transition-all hover:bg-overlay hover:text-foreground group-hover:opacity-100",
visible ? "opacity-100" : "opacity-0",
)}
onClick={onClick}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>New worker in {projectName}</TooltipContent>
</Tooltip>
);
}
function CollapsedRail({
agents,
needYou,
workspaces,
onCreateProject,
}: {
agents: number;
needYou: number;
workspaces: WorkspaceSummary[];
onCreateProject: SidebarProps["onCreateProject"];
}) {
const { view, selectedWorkspaceId, selectOrchestrator, selectWorkspace } = useUiStore();
return (
<aside className="flex h-full w-12 shrink-0 flex-col items-center border-r border-border bg-sidebar py-2">
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="Orchestrator"
className={cn(
"relative grid h-9 w-9 place-items-center rounded-lg transition-colors",
"before:absolute before:left-0 before:top-2 before:bottom-2 before:w-0.5 before:rounded-r before:bg-accent",
view === "orchestrator" ? "bg-overlay" : "bg-raised hover:bg-overlay",
)}
onClick={selectOrchestrator}
type="button"
>
<Waypoints className="h-4 w-4 text-accent" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
Orchestrator · {agents} agents · {needYou} need you
</TooltipContent>
</Tooltip>
<div className="mt-2 flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto">
{workspaces.map((workspace) => (
<Tooltip key={workspace.id}>
<TooltipTrigger asChild>
<button
aria-label={workspace.name}
className={cn(
"grid h-9 w-9 place-items-center rounded-lg transition-colors",
selectedWorkspaceId === workspace.id && view !== "session"
? "bg-raised text-foreground"
: "text-muted-foreground hover:bg-surface",
)}
onClick={() => selectWorkspace(workspace.id)}
type="button"
>
<Folder className="h-4 w-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{workspace.name}</TooltipContent>
</Tooltip>
))}
</div>
<div className="flex flex-col items-center gap-1 border-t border-border pt-2">
<CreateProjectButton onCreateProject={onCreateProject} />
<button
aria-label="Search"
className="grid h-9 w-9 place-items-center rounded-lg text-passive transition-colors hover:bg-surface hover:text-foreground"
type="button"
>
<Search className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</aside>
);
}

View File

@ -0,0 +1,289 @@
import * as Dialog from "@radix-ui/react-dialog";
import { ChevronDown, CornerDownLeft, X } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import type { AgentProvider, WorkspaceSummary } from "../types/workspace";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
const agentOptions: { value: AgentProvider; label: string }[] = [
{ value: "claude-code", label: "claude-code" },
{ value: "codex", label: "codex" },
{ value: "opencode", label: "opencode" },
{ value: "amp", label: "amp" },
{ value: "goose", label: "goose" },
{ value: "kiro", label: "kiro" },
{ value: "kimi", label: "kimi" },
{ value: "crush", label: "crush" },
{ value: "vibe", label: "vibe" },
];
const basedOnTabs = ["Branch", "Issue", "Pull Request"] as const;
type BasedOn = (typeof basedOnTabs)[number];
const NAME_RULE = /^[a-z0-9-]+$/;
type SpawnWorkerModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaces: WorkspaceSummary[];
defaultProjectId?: string;
onCreateTask: (input: {
projectId: string;
prompt: string;
branch?: string;
harness?: AgentProvider;
}) => Promise<void>;
};
export function SpawnWorkerModal({
open,
onOpenChange,
workspaces,
defaultProjectId,
onCreateTask,
}: SpawnWorkerModalProps) {
const fallbackProjectId = defaultProjectId ?? workspaces[0]?.id ?? "";
const [name, setName] = useState("");
const [projectId, setProjectId] = useState(fallbackProjectId);
const [agent, setAgent] = useState<AgentProvider>("claude-code");
const [basedOn, setBasedOn] = useState<BasedOn>("Branch");
const [branch, setBranch] = useState("main");
const [tab, setTab] = useState<"Prompt" | "Workspace">("Prompt");
const [prompt, setPrompt] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset to the launching project each time the dialog opens.
useEffect(() => {
if (open) {
setProjectId(fallbackProjectId);
setError(null);
}
}, [open, fallbackProjectId]);
const selectedWorkspace = workspaces.find((workspace) => workspace.id === projectId) ?? workspaces[0];
const branchOptions = Array.from(
new Set(["main", ...(selectedWorkspace?.sessions.map((session) => session.branch).filter(Boolean) ?? [])]),
);
const nameValid = name === "" || NAME_RULE.test(name);
const canSubmit = prompt.trim().length > 0 && projectId !== "" && nameValid && !isSubmitting;
const submit = async (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!canSubmit) return;
setError(null);
setIsSubmitting(true);
try {
await onCreateTask({
projectId,
prompt: prompt.trim(),
branch: basedOn === "Branch" ? branch.trim() || undefined : undefined,
harness: agent,
});
setName("");
setPrompt("");
setBranch("main");
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not spawn worker");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/50 data-[state=open]:animate-overlay-in" />
<Dialog.Content
aria-describedby={undefined}
className="fixed left-1/2 top-1/2 z-50 w-[512px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-xl bg-background text-foreground shadow-[0_24px_70px_rgb(0_0_0_/_0.55)] ring-1 ring-foreground/10 focus-visible:outline-none data-[state=open]:animate-modal-in"
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
void submit();
}
}}
>
<div className="flex items-center gap-2 border-b border-border px-[18px] py-[15px]">
<Dialog.Title className="font-mono text-[11px] uppercase tracking-[0.14em] text-passive">
New worker
</Dialog.Title>
<Dialog.Close
aria-label="Close"
className="ml-auto inline-flex text-passive transition-colors hover:text-foreground"
>
<X className="h-[15px] w-[15px]" aria-hidden="true" />
</Dialog.Close>
</div>
<form className="flex flex-col gap-[15px] p-[18px]" onSubmit={submit}>
<div className="flex flex-col gap-1.5">
<input
aria-label="Worker name"
autoFocus
className="w-full border-none bg-transparent p-0 text-lg font-medium text-foreground outline-none placeholder:text-passive"
onChange={(event) => setName(event.target.value)}
placeholder="worker-name"
value={name}
/>
<span className={cn("text-[11.5px]", nameValid ? "text-passive" : "text-error")}>
Worker names allow letters, numbers, and hyphens.
</span>
</div>
<SelectRow
label="Project"
aria-label="Project"
onChange={setProjectId}
value={projectId}
options={workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name }))}
/>
<SelectRow
label="Agent"
aria-label="Agent"
onChange={(value) => setAgent(value as AgentProvider)}
value={agent}
options={agentOptions}
/>
<div className="overflow-hidden rounded-lg border border-border">
<div className="flex items-center gap-2 border-b border-border px-2.5 py-1.5">
<span className="text-[13px] text-muted-foreground">Based on</span>
<div className="ml-auto inline-flex overflow-hidden rounded-md border border-border">
{basedOnTabs.map((option) => (
<button
className={cn(
"px-2.5 py-0.5 text-[11.5px] transition-colors",
basedOn === option
? "bg-raised text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
key={option}
onClick={() => setBasedOn(option)}
type="button"
>
{option}
</button>
))}
</div>
</div>
<div className="p-2.5">
{basedOn === "Branch" ? (
<SelectControl
aria-label="Based on branch"
className="flex w-full"
onChange={setBranch}
value={branch}
options={branchOptions.map((option) => ({ value: option, label: option }))}
/>
) : (
<p className="px-1 py-1.5 text-[12.5px] text-passive">
{basedOn === "Issue" ? "Pick an issue to start from." : "Pick a pull request to start from."}
</p>
)}
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-1">
{(["Prompt", "Workspace"] as const).map((option) => (
<button
className={cn(
"rounded-md px-2.5 py-1 text-[12.5px] transition-colors",
tab === option ? "bg-raised text-foreground" : "text-muted-foreground hover:text-foreground",
)}
key={option}
onClick={() => setTab(option)}
type="button"
>
{option}
</button>
))}
</div>
{tab === "Prompt" ? (
<textarea
aria-label="Prompt"
className="min-h-[74px] w-full resize-none rounded-md border border-border bg-transparent px-2.5 py-2 text-[13px] text-foreground outline-none placeholder:text-passive focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-weak"
onChange={(event) => setPrompt(event.target.value)}
placeholder="What should this worker do?"
value={prompt}
/>
) : (
<p className="rounded-md border border-border px-2.5 py-2 font-mono text-[12px] text-passive">
~/.rc/wt/{selectedWorkspace?.name ?? "project"}/{name || "worker-name"}
</p>
)}
</div>
{error && <p className="text-[12px] text-error">{error}</p>}
<div className="-mx-[18px] -mb-[18px] flex justify-end border-t border-border bg-surface px-3.5 py-3">
<Button disabled={!canSubmit} type="submit" variant="primary">
Spawn worker
<span className="ml-0.5 inline-flex items-center gap-0.5 rounded border border-accent-foreground/35 px-1 font-mono text-[10px] opacity-70">
<CornerDownLeft className="h-2.5 w-2.5" aria-hidden="true" />
</span>
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function SelectRow({
label,
"aria-label": ariaLabel,
value,
options,
onChange,
}: {
label: string;
"aria-label": string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
}) {
return (
<div className="flex items-center gap-2.5">
<span className="text-[13px] text-muted-foreground">{label}</span>
<div className="ml-auto">
<SelectControl aria-label={ariaLabel} onChange={onChange} options={options} value={value} />
</div>
</div>
);
}
function SelectControl({
"aria-label": ariaLabel,
value,
options,
onChange,
className,
}: {
"aria-label": string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
className?: string;
}) {
return (
<div className={cn("relative inline-flex h-7 items-center rounded-md border border-border", className)}>
<select
aria-label={ariaLabel}
className="h-full w-full cursor-pointer appearance-none bg-transparent pl-2.5 pr-7 text-[13px] text-foreground outline-none"
onChange={(event) => onChange(event.target.value)}
value={value}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 h-[13px] w-[13px] text-passive" aria-hidden="true" />
</div>
);
}

View File

@ -0,0 +1,172 @@
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { CanvasAddon } from "@xterm/addon-canvas";
import { WebglAddon } from "@xterm/addon-webgl";
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { WebLinksAddon } from "@xterm/addon-web-links";
import type { WorkspaceSession } from "../types/workspace";
import type { Theme } from "../stores/ui-store";
import { useTerminalSession, type TerminalSessionState } from "../hooks/useTerminalSession";
type TerminalPaneProps = {
session?: WorkspaceSession;
theme: Theme;
daemonReady: boolean;
};
export function TerminalPane({ session, theme, daemonReady }: TerminalPaneProps) {
if (!window.ao) {
return (
<pre className="h-full overflow-auto bg-terminal p-4 font-mono text-[13px] leading-relaxed text-[var(--term-fg)]">
<span className="text-[var(--term-dim)]">~/{session?.workspaceName ?? "reverbcode"}</span>{" "}
<span className="text-[var(--term-blue)]">{session?.branch || "main"}</span> $ {session?.provider ?? "claude"}
{"\n"}
<span className="text-[var(--term-green)]"> Welcome to the agent CLI</span>
{"\n\n"}
<span className="text-[var(--term-dim)]">
Browser preview renders a static terminal surface. Electron attaches the live PTY.
</span>
</pre>
);
}
return <XtermTerminal session={session} theme={theme} daemonReady={daemonReady} />;
}
function webgl2Available(): boolean {
try {
return Boolean(document.createElement("canvas").getContext("webgl2"));
} catch {
return false;
}
}
// Load the GPU-accelerated WebGL renderer when a real WebGL2 context is
// available, falling back to the 2D canvas renderer otherwise (software
// rendering, older GPUs). Probing first avoids loading a half-initialised
// WebglAddon that then throws on dispose. Renderer addons load after open().
function attachRenderer(terminal: Terminal): void {
if (webgl2Available()) {
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
terminal.loadAddon(webgl);
return;
} catch {
// WebGL init failed despite the probe; fall through to canvas.
}
}
try {
terminal.loadAddon(new CanvasAddon());
} catch {
// The renderer addon is an optimisation; the DOM renderer still works.
}
}
function bannerText(state: TerminalSessionState, error?: string): string | undefined {
if (state === "reattaching") return "Terminal disconnected — reattaching…";
if (state === "error") return `Terminal error: ${error ?? "connection failed"}`;
return undefined;
}
function XtermTerminal({ session, theme, daemonReady }: TerminalPaneProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const terminalRef = useRef<Terminal | null>(null);
const { attach, state, error } = useTerminalSession(session, { daemonReady });
useEffect(() => {
if (!containerRef.current) return;
const terminal = new Terminal({
allowProposedApi: false,
cursorBlink: true,
fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 13,
lineHeight: 1.35,
theme: terminalTheme(theme),
});
terminalRef.current = terminal;
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(new WebLinksAddon());
terminal.loadAddon(new SearchAddon());
terminal.open(containerRef.current);
attachRenderer(terminal);
let detach: (() => void) | undefined;
let rafId: number | undefined;
// The attachment forwards size changes itself (terminal.onResize → mux);
// the component only owns fitting the terminal to its container.
const fitTerminal = () => {
if (!containerRef.current?.clientWidth || !containerRef.current.clientHeight) return;
try {
fitAddon.fit();
} catch {
// Electron can report zero-sized panels during startup; the next resize will retry.
}
};
if (session?.terminalHandleId) {
rafId = requestAnimationFrame(() => {
fitTerminal();
detach = attach(terminal);
});
} else {
rafId = requestAnimationFrame(fitTerminal);
terminal.writeln("Agent Orchestrator");
terminal.writeln("");
terminal.writeln("\x1b[2mNo session selected. Pick a worker to attach its terminal.\x1b[0m");
}
const resizeObserver = new ResizeObserver(fitTerminal);
resizeObserver.observe(containerRef.current);
return () => {
if (rafId !== undefined) cancelAnimationFrame(rafId);
resizeObserver.disconnect();
detach?.();
terminalRef.current = null;
try {
terminal.dispose();
} catch {
// Some xterm renderer addons can throw during dispose in certain GPU
// environments; the terminal is being torn down regardless.
}
};
}, [session?.id, session?.terminalHandleId, attach]);
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.options.theme = terminalTheme(theme);
}
}, [theme]);
const banner = bannerText(state, error);
return (
<div className="relative h-full min-h-0">
<div ref={containerRef} className="h-full min-h-0 bg-terminal p-3" />
{banner && (
<div className="absolute inset-x-3 top-2 rounded-md border border-border bg-surface/95 px-3 py-1.5 font-mono text-[11px] text-muted-foreground">
{banner}
</div>
)}
</div>
);
}
// The terminal is the agent CLI; it keeps the emdash dark palette (green cursor) in
// both themes — see DESIGN.md → Color. The `theme` arg is kept for the signature the
// caller uses on theme change.
function terminalTheme(_theme: Theme) {
return {
background: "#161616",
foreground: "#d7d7d2",
cursor: "#7bd88f",
cursorAccent: "#161616",
selectionBackground: "rgba(63, 142, 247, 0.35)",
};
}

View File

@ -0,0 +1,194 @@
import {
Columns2,
FileText,
GitPullRequest,
MoreHorizontal,
PanelLeft,
Pin,
Plus,
Terminal,
Waypoints,
} from "lucide-react";
import type { WorkbenchTab, WorkbenchView } from "../stores/ui-store";
import type { WorkspaceSession, WorkspaceSummary } from "../types/workspace";
import { workerDisplayStatus } from "../types/workspace";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { cn } from "../lib/utils";
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const dragStyle = isMac ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined;
const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;
type TopbarProps = {
view: WorkbenchView;
session?: WorkspaceSession;
workspace?: WorkspaceSummary;
workbenchTab: WorkbenchTab;
onSetWorkbenchTab: (tab: WorkbenchTab) => void;
onNewWorker: () => void;
onToggleSidebar: () => void;
};
export function Topbar({
view,
session,
workspace,
workbenchTab,
onSetWorkbenchTab,
onNewWorker,
onToggleSidebar,
}: TopbarProps) {
return (
<header
className="flex h-11 shrink-0 items-center gap-2.5 border-b border-border bg-background px-3"
style={dragStyle}
>
{isMac && <span className="inline-block w-[66px] shrink-0" />}
<button
aria-label="Toggle sidebar"
className="grid h-7 w-7 shrink-0 place-items-center rounded-md text-passive transition-colors hover:bg-raised hover:text-muted-foreground"
onClick={onToggleSidebar}
style={noDragStyle}
title="Toggle sidebar (⌘B)"
type="button"
>
<PanelLeft className="h-[15px] w-[15px]" aria-hidden="true" />
</button>
{view === "orchestrator" ? (
<div className="flex min-w-0 items-center gap-2 text-[13px] text-muted-foreground">
<Waypoints className="h-[15px] w-[15px] shrink-0 text-accent" aria-hidden="true" />
<span className="truncate font-medium text-foreground">Orchestrator</span>
</div>
) : (
<div className="flex min-w-0 items-center gap-1.5 text-[13px] text-muted-foreground">
<span className="truncate">{session?.workspaceName ?? workspace?.name ?? "—"}</span>
<span className="text-passive">/</span>
<span className="truncate font-medium text-foreground">{session?.title ?? "session"}</span>
<Pin className="h-3 w-3 shrink-0 text-passive" aria-hidden="true" />
</div>
)}
<div className="ml-auto flex shrink-0 items-center gap-0.5">
{view === "orchestrator" ? (
<>
<button
aria-label="New worker"
className="mr-1 inline-flex h-6 items-center gap-1.5 rounded-md border border-border px-2.5 text-[11.5px] text-muted-foreground transition-colors hover:border-accent hover:text-accent"
onClick={onNewWorker}
style={noDragStyle}
type="button"
>
<Plus className="h-3 w-3" aria-hidden="true" />
New worker
</button>
<IconToggle label="Terminal" active>
<Terminal className="h-[15px] w-[15px]" aria-hidden="true" />
</IconToggle>
<IconToggle label="More">
<MoreHorizontal className="h-[15px] w-[15px]" aria-hidden="true" />
</IconToggle>
</>
) : (
<>
<PrPill session={session} workspace={workspace} />
<IconToggle
label="Changes"
active={workbenchTab === "changes"}
onClick={() => onSetWorkbenchTab("changes")}
>
<Columns2 className="h-[15px] w-[15px]" aria-hidden="true" />
</IconToggle>
<IconToggle label="Files" active={workbenchTab === "files"} onClick={() => onSetWorkbenchTab("files")}>
<FileText className="h-[15px] w-[15px]" aria-hidden="true" />
</IconToggle>
<IconToggle
label="Terminal"
active={workbenchTab === "terminal"}
onClick={() => onSetWorkbenchTab("terminal")}
>
<Terminal className="h-[15px] w-[15px]" aria-hidden="true" />
</IconToggle>
<IconToggle label="Session actions">
<MoreHorizontal className="h-[15px] w-[15px]" aria-hidden="true" />
</IconToggle>
</>
)}
</div>
</header>
);
}
function IconToggle({
label,
active = false,
onClick,
children,
}: {
label: string;
active?: boolean;
onClick?: () => void;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={label}
aria-pressed={active}
className={cn(
"grid h-7 w-7 place-items-center rounded-md transition-colors",
active ? "bg-accent-weak text-accent" : "text-passive hover:bg-raised hover:text-muted-foreground",
)}
onClick={onClick}
style={noDragStyle}
type="button"
>
{children}
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
function PrPill({ session, workspace }: { session?: WorkspaceSession; workspace?: WorkspaceSummary }) {
const pr = session?.pullRequest ?? workspace?.pullRequest;
const status = session ? workerDisplayStatus(session) : "working";
if (!pr) {
return (
<button
className="mr-1 inline-flex h-6 items-center gap-1.5 rounded-md border border-border px-2.5 text-[11.5px] font-medium text-muted-foreground transition-colors hover:border-accent hover:text-accent"
style={noDragStyle}
type="button"
>
<GitPullRequest className="h-3 w-3" aria-hidden="true" />
Open PR
</button>
);
}
const tone =
status === "ci_failed"
? "border-error/40 bg-error/10 text-error"
: status === "needs_you"
? "border-warning/40 bg-warning/10 text-warning"
: "border-success/40 bg-success/10 text-success";
const label = status === "ci_failed" ? "CI failed" : status === "needs_you" ? "review requested" : "mergeable";
return (
<button
className={cn(
"mr-1 inline-flex h-6 items-center gap-1.5 whitespace-nowrap rounded-md border px-2.5 text-[11.5px] font-medium",
tone,
)}
style={noDragStyle}
title={`PR #${pr.number}${label}`}
type="button"
>
<GitPullRequest className="h-3 w-3" aria-hidden="true" />
PR #{pr.number} · {label}
</button>
);
}

View File

@ -0,0 +1,27 @@
import * as React from "react";
import { cn } from "../../lib/utils";
// Mono pill badges, like emdash. Color is rare and meaningful (DESIGN.md → Color).
type BadgeVariant = "neutral" | "outline" | "accent" | "success" | "warning" | "error";
export function Badge({
className,
variant = "neutral",
...props
}: React.HTMLAttributes<HTMLSpanElement> & { variant?: BadgeVariant }) {
return (
<span
className={cn(
"inline-flex h-[18px] shrink-0 items-center gap-1 rounded-full border border-transparent px-2 font-mono text-[10px] font-medium",
variant === "neutral" && "bg-raised text-muted-foreground",
variant === "outline" && "border-border text-foreground",
variant === "accent" && "border-accent-dim text-accent",
variant === "success" && "border-success/40 text-success",
variant === "warning" && "border-warning/40 text-warning",
variant === "error" && "border-error/40 text-error",
className,
)}
{...props}
/>
);
}

View File

@ -0,0 +1,44 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
// emdash buttons are font-normal (400) with 6px radius; blue is the live edge
// (primary). See DESIGN.md → Spacing / Color.
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-[13px] font-normal transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-45",
{
variants: {
variant: {
primary: "border border-primary bg-primary text-primary-foreground hover:opacity-90",
outline: "border border-border bg-background text-foreground hover:bg-surface",
secondary: "bg-raised text-muted-foreground hover:text-foreground",
ghost: "text-muted-foreground hover:bg-surface hover:text-foreground",
},
size: {
default: "h-8 px-3",
sm: "h-7 px-2.5 text-xs",
icon: "h-8 w-8",
"icon-sm": "h-7 w-7",
},
},
defaultVariants: {
variant: "primary",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ asChild = false, className, size, variant, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";

View File

@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type = "text", ...props }, ref) => (
<input
className={cn(
"flex h-8 w-full rounded-md border border-border bg-transparent px-3 text-[13px] text-foreground transition-colors placeholder:text-passive focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-weak disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
type={type}
{...props}
/>
),
);
Input.displayName = "Input";

View File

@ -0,0 +1,29 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "../../lib/utils";
export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn("inline-flex h-8 items-center justify-center gap-1 rounded-md bg-raised p-1", className)}
{...props}
/>
);
}
export function TabsTrigger({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
"inline-flex h-6 items-center justify-center whitespace-nowrap rounded px-2.5 text-xs font-normal text-muted-foreground transition-colors data-[state=active]:bg-background data-[state=active]:text-foreground",
className,
)}
{...props}
/>
);
}
export function TabsContent({ className, ...props }: React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content className={cn("focus-visible:outline-none", className)} {...props} />;
}

View File

@ -0,0 +1,25 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "../../lib/utils";
export const TooltipProvider = TooltipPrimitive.Provider;
export const Tooltip = TooltipPrimitive.Root;
export const TooltipTrigger = TooltipPrimitive.Trigger;
export function TooltipContent({
className,
sideOffset = 6,
...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className={cn(
"z-50 rounded-md border border-border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md",
className,
)}
sideOffset={sideOffset}
{...props}
/>
</TooltipPrimitive.Portal>
);
}

9
frontend/src/renderer/global.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
import type { AoBridge } from "../preload";
declare global {
interface Window {
ao?: AoBridge;
}
}
export {};

View File

@ -0,0 +1,102 @@
import { renderHook, waitFor } from "@testing-library/react";
import { act } from "react";
import type { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getStatusMock, onStatusMock, removeStatusMock, connectMock, stopTransportMock, setApiBaseUrlMock } = vi.hoisted(
() => ({
getStatusMock: vi.fn(),
onStatusMock: vi.fn(),
removeStatusMock: vi.fn(),
connectMock: vi.fn(),
stopTransportMock: vi.fn(),
setApiBaseUrlMock: vi.fn(),
}),
);
vi.mock("../lib/bridge", () => ({
aoBridge: { daemon: { getStatus: getStatusMock, onStatus: onStatusMock } },
}));
vi.mock("../lib/event-transport", () => ({
createEventTransport: vi.fn(() => ({ connect: connectMock })),
}));
vi.mock("../lib/api-client", () => ({
setApiBaseUrl: setApiBaseUrlMock,
}));
import { useDaemonStatus } from "./useDaemonStatus";
type DaemonStatus = { state: "starting" | "ready" | "stopped" | "error"; port?: number; message?: string };
function fakeQueryClient(): QueryClient {
return { invalidateQueries: vi.fn() } as unknown as QueryClient;
}
beforeEach(() => {
getStatusMock.mockReset().mockResolvedValue({ state: "stopped" });
onStatusMock.mockReset().mockReturnValue(removeStatusMock);
removeStatusMock.mockReset();
connectMock.mockReset().mockReturnValue(stopTransportMock);
stopTransportMock.mockReset();
setApiBaseUrlMock.mockReset();
});
describe("useDaemonStatus", () => {
it("applies the initial status, points REST at the reported port, and connects the transport", async () => {
getStatusMock.mockResolvedValue({ state: "ready", port: 3037 });
const queryClient = fakeQueryClient();
const { result } = renderHook(() => useDaemonStatus(queryClient));
await waitFor(() => expect(result.current).toEqual({ state: "ready", port: 3037 }));
expect(setApiBaseUrlMock).toHaveBeenCalledWith("http://127.0.0.1:3037");
expect(connectMock).toHaveBeenCalledTimes(1);
// Refetching is the (debounced) event transport's job — no direct invalidate.
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
});
it("does not touch the base URL for statuses without a port", async () => {
getStatusMock.mockResolvedValue({ state: "stopped", message: "daemon not configured" });
const queryClient = fakeQueryClient();
const { result } = renderHook(() => useDaemonStatus(queryClient));
await waitFor(() => expect(result.current.message).toBe("daemon not configured"));
expect(setApiBaseUrlMock).not.toHaveBeenCalled();
});
it("applies pushed status events from the bridge", async () => {
const queryClient = fakeQueryClient();
const { result } = renderHook(() => useDaemonStatus(queryClient));
await waitFor(() => expect(onStatusMock).toHaveBeenCalled());
const pushStatus = onStatusMock.mock.calls[0][0] as (status: DaemonStatus) => void;
act(() => pushStatus({ state: "ready", port: 4555 }));
expect(result.current).toEqual({ state: "ready", port: 4555 });
expect(setApiBaseUrlMock).toHaveBeenCalledWith("http://127.0.0.1:4555");
});
it("still connects the transport when the initial IPC status call fails", async () => {
getStatusMock.mockRejectedValue(new Error("ipc unavailable"));
const queryClient = fakeQueryClient();
const { result } = renderHook(() => useDaemonStatus(queryClient));
await waitFor(() => expect(connectMock).toHaveBeenCalledTimes(1));
expect(result.current).toEqual({ state: "stopped" });
});
it("tears down the transport and the status listener on unmount", async () => {
const queryClient = fakeQueryClient();
const { unmount } = renderHook(() => useDaemonStatus(queryClient));
await waitFor(() => expect(connectMock).toHaveBeenCalledTimes(1));
unmount();
expect(stopTransportMock).toHaveBeenCalledTimes(1);
expect(removeStatusMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,48 @@
import { useEffect, useState } from "react";
import type { QueryClient } from "@tanstack/react-query";
import { aoBridge } from "../lib/bridge";
import { queryClient as defaultQueryClient } from "../lib/query-client";
import { createEventTransport } from "../lib/event-transport";
import { setApiBaseUrl } from "../lib/api-client";
type DaemonStatus = Awaited<ReturnType<typeof aoBridge.daemon.getStatus>>;
export function useDaemonStatus(queryClient: QueryClient = defaultQueryClient) {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
useEffect(() => {
let active = true;
let stopTransport: () => void = () => undefined;
const applyStatus = (nextStatus: DaemonStatus) => {
// Only point REST at the new port; the workspace refetch is the event
// transport's job (it invalidates, debounced, on every daemon status).
if (nextStatus.port) {
setApiBaseUrl(`http://127.0.0.1:${nextStatus.port}`);
}
setStatus(nextStatus);
};
void aoBridge.daemon
.getStatus()
.then((nextStatus) => {
if (active) applyStatus(nextStatus);
})
.catch(() => {
// IPC unavailable (browser preview, broken preload): stay "stopped";
// REST against the default base URL still works where it can.
})
.then(() => {
if (active) stopTransport = createEventTransport(queryClient).connect();
});
const stopStatusListener = aoBridge.daemon.onStatus(applyStatus);
return () => {
active = false;
stopTransport();
stopStatusListener();
};
}, [queryClient]);
return status;
}

View File

@ -0,0 +1,11 @@
import { useSyncExternalStore } from "react";
import {
getEventsConnectionState,
subscribeEventsConnection,
type EventsConnectionState,
} from "../lib/events-connection";
/** Live connection state of the daemon SSE stream (see events-connection.ts). */
export function useEventsConnection(): EventsConnectionState {
return useSyncExternalStore(subscribeEventsConnection, getEventsConnectionState);
}

View File

@ -0,0 +1,239 @@
import { act, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MuxConnectionState, TerminalMux } from "../lib/terminal-mux";
import type { WorkspaceSession } from "../types/workspace";
import { useTerminalSession, type AttachableTerminal } from "./useTerminalSession";
import { workspaceQueryKey } from "./useWorkspaceQuery";
const session: WorkspaceSession = {
id: "sess-1",
terminalHandleId: "handle-1",
workspaceId: "ws-1",
workspaceName: "demo",
title: "fix the tests",
provider: "claude-code",
branch: "main",
status: "working",
updatedAt: "now",
};
type FakeMux = {
mux: TerminalMux;
opens: Array<[string, number, number]>;
resizes: Array<[string, number, number]>;
inputs: Array<[string, string]>;
disposed: boolean;
emitData(id: string, text: string): void;
emitOpened(id: string): void;
emitExit(id: string): void;
emitError(id: string, message: string): void;
emitConnection(state: MuxConnectionState): void;
};
function subscribe<T>(map: Map<string, Set<T>>, id: string, listener: T): () => void {
const set = map.get(id) ?? new Set<T>();
set.add(listener);
map.set(id, set);
return () => set.delete(listener);
}
function createFakeMux(): FakeMux {
const data = new Map<string, Set<(bytes: Uint8Array) => void>>();
const exit = new Map<string, Set<() => void>>();
const opened = new Map<string, Set<() => void>>();
const error = new Map<string, Set<(message: string) => void>>();
const connection = new Set<(state: MuxConnectionState) => void>();
const fake: FakeMux = {
opens: [],
resizes: [],
inputs: [],
disposed: false,
mux: {
open: (id, cols, rows) => fake.opens.push([id, cols, rows]),
sendInput: (id, input) => fake.inputs.push([id, input]),
resize: (id, cols, rows) => fake.resizes.push([id, cols, rows]),
close: () => undefined,
onData: (id, listener) => subscribe(data, id, listener),
onExit: (id, listener) => subscribe(exit, id, listener),
onOpened: (id, listener) => subscribe(opened, id, listener),
onError: (id, listener) => subscribe(error, id, listener),
onConnectionChange: (listener) => {
connection.add(listener);
return () => connection.delete(listener);
},
dispose: () => {
fake.disposed = true;
},
},
emitData: (id, text) => data.get(id)?.forEach((listener) => listener(new TextEncoder().encode(text))),
emitOpened: (id) => opened.get(id)?.forEach((listener) => listener()),
emitExit: (id) => exit.get(id)?.forEach((listener) => listener()),
emitError: (id, message) => error.get(id)?.forEach((listener) => listener(message)),
emitConnection: (state) => connection.forEach((listener) => listener(state)),
};
return fake;
}
type FakeTerminal = AttachableTerminal & {
lines: string[];
resets: number;
typeKeys(data: string): void;
emitResize(cols: number, rows: number): void;
};
function createFakeTerminal(): FakeTerminal {
const dataListeners = new Set<(data: string) => void>();
const resizeListeners = new Set<(size: { cols: number; rows: number }) => void>();
const terminal: FakeTerminal = {
cols: 80,
rows: 24,
lines: [],
resets: 0,
write: (bytes) => terminal.lines.push(new TextDecoder().decode(bytes)),
writeln: (line) => terminal.lines.push(line),
reset: () => {
terminal.resets += 1;
},
onData: (listener) => {
dataListeners.add(listener);
return { dispose: () => dataListeners.delete(listener) };
},
onResize: (listener) => {
resizeListeners.add(listener);
return { dispose: () => resizeListeners.delete(listener) };
},
typeKeys: (data) => dataListeners.forEach((listener) => listener(data)),
emitResize: (cols, rows) => resizeListeners.forEach((listener) => listener({ cols, rows })),
};
return terminal;
}
function setup({ daemonReady = true, attachedSession = session as WorkspaceSession | undefined } = {}) {
const muxes: FakeMux[] = [];
const createMux = () => {
const fake = createFakeMux();
muxes.push(fake);
return fake.mux;
};
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const view = renderHook(
({ daemonReady: ready }) => useTerminalSession(attachedSession, { daemonReady: ready, createMux }),
{ initialProps: { daemonReady }, wrapper },
);
const terminal = createFakeTerminal();
let detach: () => void = () => undefined;
act(() => {
detach = view.result.current.attach(terminal);
});
return { view, terminal, muxes, invalidateSpy, detach: () => detach() };
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("useTerminalSession", () => {
it("opens the pane at the terminal's size and reaches attached on the server ack", () => {
const { view, muxes } = setup();
expect(view.result.current.state).toBe("connecting");
expect(muxes).toHaveLength(1);
expect(muxes[0].opens).toEqual([["handle-1", 80, 24]]);
act(() => muxes[0].emitOpened("handle-1"));
expect(view.result.current.state).toBe("attached");
});
it("stays idle when the session has no terminal handle", () => {
const { view, muxes } = setup({ attachedSession: { ...session, terminalHandleId: undefined } });
expect(view.result.current.state).toBe("idle");
expect(muxes).toHaveLength(0);
});
it("forwards PTY output, keystrokes, and resizes across the attachment", () => {
const { terminal, muxes } = setup();
act(() => muxes[0].emitData("handle-1", "hello"));
expect(terminal.lines).toContain("hello");
terminal.typeKeys("ls\r");
expect(muxes[0].inputs).toEqual([["handle-1", "ls\r"]]);
terminal.emitResize(120, 40);
expect(muxes[0].resizes).toContainEqual(["handle-1", 120, 40]);
});
it("marks exit in the terminal and refetches workspace state instead of writing status", () => {
const { view, terminal, muxes, invalidateSpy } = setup();
act(() => muxes[0].emitExit("handle-1"));
expect(view.result.current.state).toBe("exited");
expect(terminal.lines.some((line) => line.includes("[process exited]"))).toBe(true);
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
});
it("surfaces pane errors and refetches, with no automatic retry", () => {
const { view, muxes, invalidateSpy } = setup();
act(() => muxes[0].emitError("handle-1", "no such pane"));
expect(view.result.current.state).toBe("error");
expect(view.result.current.error).toBe("no such pane");
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
act(() => muxes[0].emitConnection("closed"));
act(() => void vi.advanceTimersByTime(60_000));
expect(muxes).toHaveLength(1);
});
it("reattaches with a fresh mux after a socket drop, resetting the stale screen", () => {
const { view, terminal, muxes } = setup();
act(() => muxes[0].emitOpened("handle-1"));
act(() => muxes[0].emitConnection("closed"));
expect(view.result.current.state).toBe("reattaching");
act(() => void vi.advanceTimersByTime(500));
expect(muxes).toHaveLength(2);
expect(muxes[0].disposed).toBe(true);
expect(terminal.resets).toBe(1); // the server replays the output ring on open
expect(muxes[1].opens).toEqual([["handle-1", 80, 24]]);
act(() => muxes[1].emitOpened("handle-1"));
expect(view.result.current.state).toBe("attached");
});
it("backs off between failed reconnect attempts", () => {
const { muxes } = setup();
act(() => muxes[0].emitConnection("closed"));
act(() => void vi.advanceTimersByTime(500)); // attempt 1 after 500ms
expect(muxes).toHaveLength(2);
act(() => muxes[1].emitConnection("closed"));
act(() => void vi.advanceTimersByTime(500)); // attempt 2 needs 1000ms
expect(muxes).toHaveLength(2);
act(() => void vi.advanceTimersByTime(500));
expect(muxes).toHaveLength(3);
});
it("waits for daemon readiness instead of retrying, then reconnects when it flips", () => {
const { view, muxes } = setup({ daemonReady: false });
act(() => muxes[0].emitConnection("closed"));
expect(view.result.current.state).toBe("reattaching");
act(() => void vi.advanceTimersByTime(60_000));
expect(muxes).toHaveLength(1); // no retries against a dead daemon
view.rerender({ daemonReady: true });
expect(muxes).toHaveLength(2); // reconnects immediately, without backoff debt
act(() => muxes[1].emitOpened("handle-1"));
expect(view.result.current.state).toBe("attached");
});
it("detach disposes the mux, stops reattach, and returns to idle", () => {
const { view, muxes, detach } = setup();
act(() => detach());
expect(view.result.current.state).toBe("idle");
expect(muxes[0].disposed).toBe(true);
act(() => muxes[0].emitConnection("closed"));
act(() => void vi.advanceTimersByTime(60_000));
expect(muxes).toHaveLength(1);
});
});

View File

@ -0,0 +1,221 @@
// Terminal Attachment (see CONTEXT.md): the live binding between a terminal
// pane and a session's PTY over the mux. The hook owns the whole attachment
// lifecycle — open ordering, auto-reattach with backoff, error surfacing, and
// exit handling — so the pane component only renders.
//
// Status rule: the frontend never writes a session's display status. On mux
// `exited`/`error` it invalidates the workspaces query and lets the daemon's
// derived status flow back (docs/architecture.md).
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { getApiBaseUrl } from "../lib/api-client";
import { createTerminalMux, muxUrlFromApiBase, type TerminalMux } from "../lib/terminal-mux";
import type { WorkspaceSession } from "../types/workspace";
import { workspaceQueryKey } from "./useWorkspaceQuery";
/**
* The slice of xterm's Terminal the attachment needs. Structural, so tests can
* drive the hook with a tiny fake instead of a real xterm + DOM.
*/
export type AttachableTerminal = {
cols: number;
rows: number;
write: (data: Uint8Array) => void;
writeln: (line: string) => void;
reset: () => void;
onData: (listener: (data: string) => void) => { dispose: () => void };
onResize: (listener: (size: { cols: number; rows: number }) => void) => { dispose: () => void };
};
export type TerminalSessionState =
| "idle" // nothing attached (no session, or detached)
| "connecting" // first attach in flight
| "attached" // server acked the open
| "reattaching" // socket dropped; waiting on backoff or daemon readiness
| "exited" // PTY process ended; terminal kept for scrollback
| "error"; // server reported a pane error; no automatic retry
export type UseTerminalSessionOptions = {
/** Gates auto-reattach: when false, a dropped socket waits instead of retrying. */
daemonReady: boolean;
/** Test seam: build the mux client. Defaults to a fresh socket against the current API base. */
createMux?: () => TerminalMux;
};
const RETRY_BASE_MS = 500;
const RETRY_MAX_MS = 8_000;
function defaultCreateMux(): TerminalMux {
// Resolved per connect, not per hook: a daemon restart can change the port.
return createTerminalMux(muxUrlFromApiBase(getApiBaseUrl()));
}
export function useTerminalSession(session: WorkspaceSession | undefined, options: UseTerminalSessionOptions) {
const queryClient = useQueryClient();
const [state, setState] = useState<TerminalSessionState>("idle");
const [error, setError] = useState<string | undefined>(undefined);
const sessionRef = useRef(session);
sessionRef.current = session;
const optionsRef = useRef(options);
optionsRef.current = options;
const stateRef = useRef<TerminalSessionState>(state);
const connectRef = useRef<() => void>(() => undefined);
const runtime = useRef({
terminal: null as AttachableTerminal | null,
mux: null as TerminalMux | null,
handle: null as string | null,
disposers: [] as Array<() => void>,
retryTimer: null as ReturnType<typeof setTimeout> | null,
attempts: 0,
firstAttach: true,
detached: true,
});
const transition = useCallback((next: TerminalSessionState) => {
stateRef.current = next;
setState(next);
}, []);
const invalidateWorkspaces = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
}, [queryClient]);
const teardownMux = useCallback(() => {
const r = runtime.current;
if (r.retryTimer) {
clearTimeout(r.retryTimer);
r.retryTimer = null;
}
r.disposers.forEach((dispose) => dispose());
r.disposers = [];
r.mux?.dispose();
r.mux = null;
}, []);
const scheduleReattach = useCallback(() => {
const r = runtime.current;
if (r.detached || !r.terminal || !r.handle) return;
// A socket dropping after the PTY ended (or errored) changes nothing.
if (stateRef.current === "exited" || stateRef.current === "error") return;
transition("reattaching");
// Not ready → no timer; the daemonReady effect reconnects when it flips.
if (!optionsRef.current.daemonReady) return;
if (r.retryTimer) return;
const delay = Math.min(RETRY_BASE_MS * 2 ** r.attempts, RETRY_MAX_MS);
r.attempts += 1;
r.retryTimer = setTimeout(() => {
r.retryTimer = null;
connectRef.current();
}, delay);
}, [transition]);
const connect = useCallback(() => {
const r = runtime.current;
const { terminal, handle } = r;
if (!terminal || !handle || r.detached) return;
teardownMux();
const mux = (optionsRef.current.createMux ?? defaultCreateMux)();
r.mux = mux;
r.disposers.push(
mux.onData(handle, (bytes) => terminal.write(bytes)),
mux.onOpened(handle, () => {
r.attempts = 0;
setError(undefined);
transition("attached");
}),
mux.onExit(handle, () => {
terminal.writeln("\r\n\x1b[2m[process exited]\x1b[0m");
transition("exited");
invalidateWorkspaces();
}),
mux.onError(handle, (message) => {
terminal.writeln(`\r\n\x1b[2m[terminal error] ${message}\x1b[0m`);
setError(message);
transition("error");
invalidateWorkspaces();
}),
mux.onConnectionChange((connectionState) => {
if (connectionState === "closed") scheduleReattach();
}),
);
const input = terminal.onData((data) => mux.sendInput(handle, data));
const resize = terminal.onResize(({ cols, rows }) => mux.resize(handle, cols, rows));
r.disposers.push(
() => input.dispose(),
() => resize.dispose(),
);
if (r.firstAttach) {
terminal.writeln(`\x1b[2mAttaching to ${sessionRef.current?.title ?? handle}\x1b[0m`);
} else {
// The server replays the recent-output ring on open (backend
// internal/terminal/ring.go); drop the stale screen so it isn't doubled.
terminal.reset();
}
r.firstAttach = false;
mux.open(handle, terminal.cols, terminal.rows);
mux.resize(handle, terminal.cols, terminal.rows);
}, [invalidateWorkspaces, scheduleReattach, teardownMux, transition]);
connectRef.current = connect;
/**
* Bind a terminal to the current session's PTY. Call once the terminal is
* opened (and fitted); returns the detach function for effect cleanup.
*/
const attach = useCallback(
(terminal: AttachableTerminal) => {
const r = runtime.current;
const handle = sessionRef.current?.terminalHandleId ?? null;
r.terminal = terminal;
r.handle = handle;
r.detached = false;
r.attempts = 0;
r.firstAttach = true;
setError(undefined);
if (handle) {
transition("connecting");
connect();
} else {
transition("idle");
}
return () => {
r.detached = true;
teardownMux();
r.terminal = null;
r.handle = null;
setError(undefined);
transition("idle");
};
},
[connect, teardownMux, transition],
);
// Daemon came back while we were waiting: reconnect immediately, without
// backoff debt from attempts made against the dead daemon.
const daemonReady = options.daemonReady;
useEffect(() => {
const r = runtime.current;
if (!daemonReady || r.detached) return;
if (stateRef.current !== "reattaching" || r.retryTimer) return;
r.attempts = 0;
connect();
}, [daemonReady, connect]);
// Belt-and-braces: never leak a socket past unmount, even if the owner
// forgot to call detach.
useEffect(
() => () => {
runtime.current.detached = true;
teardownMux();
},
[teardownMux],
);
return { attach, state, error };
}

View File

@ -0,0 +1,141 @@
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() }));
vi.mock("../lib/api-client", () => ({
apiClient: { GET: getMock },
}));
import { useWorkspaceQuery } from "./useWorkspaceQuery";
function wrapper({ children }: { children: ReactNode }) {
// The hook pins its own retry policy; retryDelay 0 keeps the error tests fast.
const queryClient = new QueryClient({ defaultOptions: { queries: { retryDelay: 0 } } });
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
function respondWith(payload: {
projects?: { data?: unknown; error?: unknown };
sessions?: { data?: unknown; error?: unknown };
}) {
getMock.mockImplementation(async (url: string) => {
if (url === "/api/v1/projects") return payload.projects ?? { data: { projects: [] }, error: undefined };
if (url === "/api/v1/sessions") return payload.sessions ?? { data: { sessions: [] }, error: undefined };
throw new Error(`unexpected GET ${url}`);
});
}
beforeEach(() => {
getMock.mockReset();
});
describe("useWorkspaceQuery", () => {
it("maps projects and their sessions, applying provider/status/title fallbacks", async () => {
respondWith({
projects: {
data: { projects: [{ id: "proj-1", name: "my-app", path: "/home/me/my-app" }] },
error: undefined,
},
sessions: {
data: {
sessions: [
{
id: "sess-1",
projectId: "proj-1",
terminalHandleId: "term-1",
displayName: "fix-bug",
harness: "claude-code",
status: "mergeable",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
},
{
// Unknown harness/status and no displayName/issueId: falls back
// to codex / working / the session id.
id: "sess-2",
projectId: "proj-1",
harness: "mystery-agent",
status: "bogus",
isTerminated: false,
updatedAt: "2026-06-10T16:15:04Z",
},
// Belongs to another project; must not leak into proj-1.
{ id: "sess-3", projectId: "proj-2", isTerminated: false, updatedAt: "2026-06-10T16:15:04Z" },
],
},
error: undefined,
},
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const [workspace] = result.current.data ?? [];
expect(workspace).toMatchObject({ id: "proj-1", name: "my-app", path: "/home/me/my-app" });
expect(workspace.sessions).toHaveLength(2);
expect(workspace.sessions[0]).toMatchObject({
id: "sess-1",
terminalHandleId: "term-1",
title: "fix-bug",
provider: "claude-code",
status: "mergeable",
});
expect(workspace.sessions[1]).toMatchObject({
id: "sess-2",
title: "sess-2",
provider: "codex",
status: "working",
});
});
it("marks terminated sessions regardless of their reported status", async () => {
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: {
data: {
sessions: [
{
id: "sess-1",
projectId: "proj-1",
status: "working",
isTerminated: true,
updatedAt: "2026-06-10T16:15:04Z",
},
],
},
error: undefined,
},
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.[0].sessions[0].status).toBe("terminated");
});
it("surfaces a projects fetch error", async () => {
const failure = new TypeError("Failed to fetch");
respondWith({ projects: { data: undefined, error: failure } });
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 3_000 });
expect(result.current.error).toBe(failure);
});
it("surfaces a sessions fetch error even when projects load", async () => {
const failure = new Error("sessions backend down");
respondWith({
projects: { data: { projects: [{ id: "proj-1", name: "my-app", path: "/p" }] }, error: undefined },
sessions: { data: undefined, error: failure },
});
const { result } = renderHook(() => useWorkspaceQuery(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 3_000 });
expect(result.current.error).toBe(failure);
});
});

View File

@ -0,0 +1,46 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api-client";
import { mockWorkspaces } from "../lib/mock-data";
import { toAgentProvider, toSessionStatus, type WorkspaceSummary } from "../types/workspace";
export const workspaceQueryKey = ["workspaces"] as const;
const usePreviewData = import.meta.env.VITE_NO_ELECTRON === "1";
async function fetchWorkspaces(): Promise<WorkspaceSummary[]> {
if (usePreviewData) {
return mockWorkspaces;
}
const [{ data: projectsData, error: projectsError }, { data: sessionsData, error: sessionsError }] =
await Promise.all([apiClient.GET("/api/v1/projects"), apiClient.GET("/api/v1/sessions")]);
if (projectsError || sessionsError) throw projectsError ?? sessionsError;
return (projectsData?.projects ?? []).map((project) => ({
id: project.id,
name: project.name,
path: project.path,
sessions: (sessionsData?.sessions ?? [])
.filter((session) => session.projectId === project.id)
.map((session) => ({
id: session.id,
terminalHandleId: session.terminalHandleId,
workspaceId: project.id,
workspaceName: project.name,
title: session.displayName ?? session.issueId ?? session.id,
provider: toAgentProvider(session.harness),
branch: "",
status: toSessionStatus(session.status, session.isTerminated),
updatedAt: new Date(session.updatedAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }),
})),
}));
}
export function useWorkspaceQuery() {
return useQuery({
queryKey: workspaceQueryKey,
queryFn: fetchWorkspaces,
retry: 1,
refetchInterval: 15_000,
});
}

View File

@ -0,0 +1,143 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { apiClient, getApiBaseUrl, setApiBaseUrl, subscribeApiBaseUrl } from "./api-client";
describe("apiClient runtime base URL", () => {
afterEach(() => {
vi.restoreAllMocks();
setApiBaseUrl("http://127.0.0.1:3001");
});
it("rewrites requests to the current runtime daemon port", async () => {
const seenUrls: string[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
seenUrls.push(input instanceof Request ? input.url : input.toString());
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
setApiBaseUrl("http://127.0.0.1:3037/");
const { error } = await apiClient.GET("/api/v1/projects");
expect(error).toBeUndefined();
expect(getApiBaseUrl()).toBe("http://127.0.0.1:3037");
expect(seenUrls).toEqual(["http://127.0.0.1:3037/api/v1/projects"]);
});
it("rebases POSTs without Request-as-init, preserving method, body, and headers", async () => {
// Regression: `new Request(target, input)` needs the source request's
// `duplex` getter, which Electron's Chromium lacks — every request with a
// body threw. The rewrite must copy fields explicitly instead.
const seen: { url: string; method?: string; body?: string; contentType?: string | null }[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
seen.push({
url: input instanceof Request ? input.url : input.toString(),
method: init?.method,
body: init?.body instanceof ArrayBuffer ? new TextDecoder().decode(init.body) : undefined,
contentType: headers.get("content-type"),
});
return new Response(JSON.stringify({ session: { id: "s1" } }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
});
setApiBaseUrl("http://127.0.0.1:3037");
const { error } = await apiClient.POST("/api/v1/sessions", {
body: { projectId: "p1", prompt: "hello" },
});
expect(error).toBeUndefined();
expect(seen).toHaveLength(1);
expect(seen[0].url).toBe("http://127.0.0.1:3037/api/v1/sessions");
expect(seen[0].method).toBe("POST");
expect(seen[0].contentType).toBe("application/json");
expect(JSON.parse(seen[0].body ?? "{}")).toEqual({ projectId: "p1", prompt: "hello" });
});
it("skips the rebase when the request already targets the runtime base URL", async () => {
const seen: (RequestInfo | URL)[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
seen.push(input);
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
// Match the base openapi-fetch built the request against (the dev origin
// in jsdom), so the rewrite has nothing to do.
setApiBaseUrl(window.location.origin);
const { error } = await apiClient.GET("/api/v1/projects");
expect(error).toBeUndefined();
expect(seen).toHaveLength(1);
// Untouched pass-through: fetch receives the original Request object.
expect(seen[0]).toBeInstanceOf(Request);
});
it("passes the request through untouched when the base URL is empty", async () => {
const seen: Request[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => {
seen.push(input as Request);
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
setApiBaseUrl("");
const { error } = await apiClient.GET("/api/v1/projects");
expect(error).toBeUndefined();
expect(getApiBaseUrl()).toBe("");
// Empty base → no rewrite; openapi-fetch's own request reaches fetch as-is.
expect(seen).toHaveLength(1);
expect(seen[0].url).toContain("/api/v1/projects");
});
});
describe("subscribeApiBaseUrl", () => {
afterEach(() => {
setApiBaseUrl("http://127.0.0.1:3001");
});
it("notifies subscribers when the base URL actually changes", () => {
const listener = vi.fn();
const unsubscribe = subscribeApiBaseUrl(listener);
try {
setApiBaseUrl("http://127.0.0.1:4555");
expect(listener).toHaveBeenCalledTimes(1);
expect(getApiBaseUrl()).toBe("http://127.0.0.1:4555");
} finally {
unsubscribe();
}
});
it("does not notify for a no-op set (same URL, trailing slash included)", () => {
setApiBaseUrl("http://127.0.0.1:4555");
const listener = vi.fn();
const unsubscribe = subscribeApiBaseUrl(listener);
try {
setApiBaseUrl("http://127.0.0.1:4555");
setApiBaseUrl("http://127.0.0.1:4555/");
expect(listener).not.toHaveBeenCalled();
} finally {
unsubscribe();
}
});
it("stops notifying after unsubscribe", () => {
const listener = vi.fn();
subscribeApiBaseUrl(listener)();
setApiBaseUrl("http://127.0.0.1:4555");
expect(listener).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,74 @@
import createClient from "openapi-fetch";
import type { paths } from "../../api/schema";
function devApiBaseUrl(): string {
return typeof window === "undefined" ? "http://127.0.0.1:3001" : window.location.origin;
}
const initialApiBaseUrl =
import.meta.env.VITE_AO_API_BASE_URL ?? (import.meta.env.DEV ? devApiBaseUrl() : "http://127.0.0.1:3001");
let runtimeApiBaseUrl = initialApiBaseUrl;
const baseUrlListeners = new Set<() => void>();
export function getApiBaseUrl(): string {
return runtimeApiBaseUrl;
}
/**
* Subscribe to base-URL changes (useSyncExternalStore-compatible). Long-lived
* connections bound to a specific port the terminal mux WebSocket, the SSE
* stream use this to rebind when the daemon comes back on a different port.
*/
export function subscribeApiBaseUrl(listener: () => void): () => void {
baseUrlListeners.add(listener);
return () => {
baseUrlListeners.delete(listener);
};
}
export function setApiBaseUrl(nextBaseUrl: string): void {
const normalized = nextBaseUrl.replace(/\/+$/, "");
if (normalized === runtimeApiBaseUrl) return;
runtimeApiBaseUrl = normalized;
baseUrlListeners.forEach((listener) => listener());
}
async function runtimeFetch(input: Request): Promise<Response> {
const baseUrl = getApiBaseUrl();
if (!baseUrl) {
return fetch(input);
}
const url = new URL(input.url);
const target = new URL(url.pathname + url.search + url.hash, baseUrl);
if (target.href === input.url) {
return fetch(input);
}
// Rebase onto the runtime base URL by copying fields explicitly and
// buffering the body. `new Request(target, input)` reads the source
// request's `duplex` getter, which Electron's Chromium lacks — it throws
// "The duplex member must be specified" for any request with a body, so
// every POST would fail in the packaged app. API bodies are small JSON;
// buffering sidesteps streaming-duplex semantics entirely.
const body = input.method === "GET" || input.method === "HEAD" ? undefined : await input.arrayBuffer();
return fetch(target, {
method: input.method,
headers: input.headers,
body,
signal: input.signal,
credentials: input.credentials,
cache: input.cache,
redirect: input.redirect,
referrerPolicy: input.referrerPolicy,
integrity: input.integrity,
keepalive: input.keepalive,
});
}
export const apiClient = createClient<paths>({
baseUrl: initialApiBaseUrl,
fetch: runtimeFetch,
});

View File

@ -0,0 +1,19 @@
import type { AoBridge } from "../../preload";
export const aoBridge: AoBridge =
window.ao ??
({
app: {
getVersion: async () => "0.0.0-preview",
chooseDirectory: async () => null,
},
daemon: {
getStatus: async () => ({
state: "stopped",
message: "Electron preload is not available in browser preview.",
}),
start: async () => ({ state: "starting" }),
stop: async () => ({ state: "stopped" }),
onStatus: () => () => undefined,
},
} satisfies AoBridge);

View File

@ -0,0 +1,193 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { onStatusMock, removeStatusMock, getApiBaseUrlMock, subscribeApiBaseUrlMock, unsubscribeBaseUrlMock } =
vi.hoisted(() => ({
onStatusMock: vi.fn(),
removeStatusMock: vi.fn(),
getApiBaseUrlMock: vi.fn(() => "http://127.0.0.1:3001"),
subscribeApiBaseUrlMock: vi.fn(),
unsubscribeBaseUrlMock: vi.fn(),
}));
vi.mock("./bridge", () => ({
aoBridge: {
daemon: { onStatus: onStatusMock },
},
}));
vi.mock("./api-client", () => ({
getApiBaseUrl: getApiBaseUrlMock,
subscribeApiBaseUrl: subscribeApiBaseUrlMock,
}));
import { createEventTransport } from "./event-transport";
import { getEventsConnectionState, setEventsConnectionState } from "./events-connection";
class EventSourceStub {
static instances: EventSourceStub[] = [];
url: string;
closed = false;
readyState = 0; // CONNECTING
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
onmessage: (() => void) | null = null;
listeners: string[] = [];
constructor(url: string) {
this.url = url;
EventSourceStub.instances.push(this);
}
addEventListener(type: string) {
this.listeners.push(type);
}
close() {
this.closed = true;
this.readyState = 2; // CLOSED
}
}
function fakeQueryClient() {
return { invalidateQueries: vi.fn() } as unknown as Parameters<typeof createEventTransport>[0];
}
beforeEach(() => {
EventSourceStub.instances = [];
onStatusMock.mockReset().mockReturnValue(removeStatusMock);
removeStatusMock.mockReset();
getApiBaseUrlMock.mockReset().mockReturnValue("http://127.0.0.1:3001");
subscribeApiBaseUrlMock.mockReset().mockReturnValue(unsubscribeBaseUrlMock);
unsubscribeBaseUrlMock.mockReset();
setEventsConnectionState("idle");
(globalThis as unknown as { EventSource: unknown }).EventSource = EventSourceStub;
});
afterEach(() => {
delete (globalThis as unknown as { EventSource?: unknown }).EventSource;
});
describe("createEventTransport", () => {
it("opens a single SSE connection to the current base URL on connect", () => {
createEventTransport(fakeQueryClient()).connect();
expect(EventSourceStub.instances).toHaveLength(1);
expect(EventSourceStub.instances[0].url).toBe("http://127.0.0.1:3001/api/v1/events");
// All CDC event types plus onmessage are wired up.
expect(EventSourceStub.instances[0].listeners).toContain("session_updated");
expect(EventSourceStub.instances[0].onmessage).toBeTypeOf("function");
});
it("does not reconnect when a daemon status keeps the same base URL", () => {
createEventTransport(fakeQueryClient()).connect();
const onStatusHandler = onStatusMock.mock.calls[0][0] as () => void;
onStatusHandler();
expect(EventSourceStub.instances).toHaveLength(1);
});
it("closes the old connection and reconnects when the base URL changes", () => {
createEventTransport(fakeQueryClient()).connect();
const first = EventSourceStub.instances[0];
const onStatusHandler = onStatusMock.mock.calls[0][0] as () => void;
getApiBaseUrlMock.mockReturnValue("http://127.0.0.1:3099");
onStatusHandler();
expect(first.closed).toBe(true);
expect(EventSourceStub.instances).toHaveLength(2);
expect(EventSourceStub.instances[1].url).toBe("http://127.0.0.1:3099/api/v1/events");
});
it("debounces a workspace invalidation after a status change", () => {
vi.useFakeTimers();
try {
const queryClient = fakeQueryClient();
createEventTransport(queryClient).connect();
const onStatusHandler = onStatusMock.mock.calls[0][0] as () => void;
onStatusHandler();
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("tears down the source and the daemon listener on disconnect", () => {
const disconnect = createEventTransport(fakeQueryClient()).connect();
disconnect();
expect(EventSourceStub.instances[0].closed).toBe(true);
expect(removeStatusMock).toHaveBeenCalledTimes(1);
});
it("is a no-op when EventSource is unavailable", () => {
delete (globalThis as unknown as { EventSource?: unknown }).EventSource;
expect(() => createEventTransport(fakeQueryClient()).connect()).not.toThrow();
expect(EventSourceStub.instances).toHaveLength(0);
});
it("marks the stream connected on open and disconnected on error", () => {
createEventTransport(fakeQueryClient()).connect();
const source = EventSourceStub.instances[0];
source.readyState = 1; // OPEN
source.onopen?.();
expect(getEventsConnectionState()).toBe("connected");
source.readyState = 0; // CONNECTING — browser is auto-retrying
source.onerror?.();
expect(getEventsConnectionState()).toBe("disconnected");
source.readyState = 1;
source.onopen?.();
expect(getEventsConnectionState()).toBe("connected");
});
it("rebuilds a source the browser abandoned after the retry delay", () => {
vi.useFakeTimers();
try {
createEventTransport(fakeQueryClient()).connect();
const source = EventSourceStub.instances[0];
source.readyState = 2; // CLOSED — EventSource gave up for good
source.onerror?.();
expect(EventSourceStub.instances).toHaveLength(1);
vi.advanceTimersByTime(5_000);
expect(EventSourceStub.instances).toHaveLength(2);
expect(EventSourceStub.instances[1].url).toBe("http://127.0.0.1:3001/api/v1/events");
} finally {
vi.useRealTimers();
}
});
it("reconnects when the API base URL changes out-of-band", () => {
createEventTransport(fakeQueryClient()).connect();
expect(subscribeApiBaseUrlMock).toHaveBeenCalledTimes(1);
const onBaseUrlChange = subscribeApiBaseUrlMock.mock.calls[0][0] as () => void;
const first = EventSourceStub.instances[0];
getApiBaseUrlMock.mockReturnValue("http://127.0.0.1:4555");
onBaseUrlChange();
expect(first.closed).toBe(true);
expect(EventSourceStub.instances).toHaveLength(2);
expect(EventSourceStub.instances[1].url).toBe("http://127.0.0.1:4555/api/v1/events");
});
it("resets the connection state and unsubscribes on disconnect", () => {
const disconnect = createEventTransport(fakeQueryClient()).connect();
const source = EventSourceStub.instances[0];
source.readyState = 1;
source.onopen?.();
expect(getEventsConnectionState()).toBe("connected");
disconnect();
expect(getEventsConnectionState()).toBe("idle");
expect(unsubscribeBaseUrlMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,118 @@
import type { QueryClient } from "@tanstack/react-query";
import { aoBridge } from "./bridge";
import { getApiBaseUrl, subscribeApiBaseUrl } from "./api-client";
import { setEventsConnectionState } from "./events-connection";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
export type EventTransport = {
connect: () => () => void;
};
const INVALIDATE_DEBOUNCE_MS = 150;
// How long to wait before rebuilding an EventSource the browser gave up on
// (readyState CLOSED — e.g. the daemon answered with a non-SSE response).
const SSE_RETRY_MS = 5_000;
// EventSource.CLOSED, referenced numerically so test stubs without the static
// constants still work.
const EVENTSOURCE_CLOSED = 2;
// CDC event types the daemon pushes over the SSE stream (see
// backend/internal/cdc/event.go). The SSE writer tags each frame with
// `event: <type>`, so named events bypass EventSource.onmessage and must be
// subscribed explicitly. Every one of these can change the project/session list
// the sidebar renders, so they all trigger a (debounced) workspace refetch.
const CDC_EVENT_TYPES = [
"session_created",
"session_updated",
"pr_created",
"pr_updated",
"pr_check_recorded",
"pr_session_changed",
"pr_review_thread_added",
"pr_review_thread_resolved",
] as const;
/**
* Wires live server state into the TanStack Query cache. Two sources feed it:
* - daemon lifecycle over Electron IPC (coming up/down changes session availability)
* - the backend CDC stream over SSE (project/session/PR changes)
* Both invalidate the ["workspaces"] query so the UI refetches. Invalidations are
* debounced because a single user action can emit a burst of CDC events.
*/
export function createEventTransport(queryClient: QueryClient): EventTransport {
return {
connect() {
let debounce: ReturnType<typeof setTimeout> | undefined;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let source: EventSource | undefined;
let sourceBaseUrl: string | undefined;
const refreshWorkspaces = () => {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => {
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
}, INVALIDATE_DEBOUNCE_MS);
};
const scheduleRetry = () => {
if (retryTimer) return;
retryTimer = setTimeout(() => {
retryTimer = undefined;
connectSource();
}, SSE_RETRY_MS);
};
const connectSource = () => {
// EventSource is unavailable in jsdom (tests) and some preview surfaces; guard it.
if (typeof EventSource === "undefined") return;
const baseUrl = getApiBaseUrl();
// Keep a still-usable source on the same base URL; replace one the
// browser abandoned (CLOSED) or one bound to a stale port.
if (source && sourceBaseUrl === baseUrl && source.readyState !== EVENTSOURCE_CLOSED) return;
source?.close();
source = undefined;
sourceBaseUrl = baseUrl;
try {
source = new EventSource(`${baseUrl.replace(/\/+$/, "")}/api/v1/events`);
source.onopen = () => {
setEventsConnectionState("connected");
// Events emitted during the gap were lost; refetch once on (re)open.
refreshWorkspaces();
};
source.onerror = () => {
// While readyState is CONNECTING the browser retries on its own;
// either way the stream is not delivering, so surface it instead
// of looping silently against a dead daemon.
setEventsConnectionState("disconnected");
if (source?.readyState === EVENTSOURCE_CLOSED) scheduleRetry();
};
source.onmessage = refreshWorkspaces; // unnamed events, if any
for (const type of CDC_EVENT_TYPES) {
source.addEventListener(type, refreshWorkspaces);
}
// EventSource auto-reconnects and resumes via Last-Event-ID while
// CONNECTING; scheduleRetry only covers the terminal CLOSED state.
} catch {
source = undefined;
}
};
const removeDaemonListener = aoBridge.daemon.onStatus(() => {
connectSource();
refreshWorkspaces();
});
// Rebind when the daemon comes back on a different port, independent of
// status-event ordering.
const removeBaseUrlListener = subscribeApiBaseUrl(connectSource);
connectSource();
return () => {
if (debounce) clearTimeout(debounce);
if (retryTimer) clearTimeout(retryTimer);
removeDaemonListener();
removeBaseUrlListener();
source?.close();
setEventsConnectionState("idle");
};
},
};
}

View File

@ -0,0 +1,27 @@
// Connection state of the daemon's SSE event stream, kept as a tiny external
// store (useSyncExternalStore-compatible) so the transport — which lives
// outside React — can drive UI signals like the sidebar's "events offline".
export type EventsConnectionState =
| "idle" // no stream (transport not connected, or torn down)
| "connected" // stream open; live updates flowing
| "disconnected"; // stream lost; UI state may be stale until it reconnects
let state: EventsConnectionState = "idle";
const listeners = new Set<() => void>();
export function getEventsConnectionState(): EventsConnectionState {
return state;
}
export function setEventsConnectionState(next: EventsConnectionState): void {
if (next === state) return;
state = next;
listeners.forEach((listener) => listener());
}
export function subscribeEventsConnection(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}

View File

@ -0,0 +1,47 @@
import type { WorkspaceSummary } from "../types/workspace";
export const mockWorkspaces: WorkspaceSummary[] = [
{
id: "api-gateway",
name: "api-gateway",
path: "/Users/me/api-gateway",
sessions: [
{
id: "refactor-mux",
terminalHandleId: "refactor-mux/terminal_0",
workspaceId: "api-gateway",
workspaceName: "api-gateway",
title: "refactor-mux",
provider: "claude-code",
branch: "feat/refactor-mux",
status: "working",
updatedAt: "now",
changedFiles: [
{
path: "internal/mux/terminal_mux.go",
additions: 42,
deletions: 8,
},
],
commitMessage: "refactor terminal mux",
},
],
},
{
id: "webgl-preview",
name: "webgl-preview",
path: "/Users/me/webgl-preview",
sessions: [
{
id: "fix-webgl-fallback",
workspaceId: "webgl-preview",
workspaceName: "webgl-preview",
title: "fix-webgl-fallback",
provider: "codex",
branch: "fix/webgl-fallback",
status: "needs_input",
updatedAt: "now",
},
],
},
];

View File

@ -0,0 +1,10 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10_000,
refetchOnWindowFocus: false,
},
},
});

View File

@ -0,0 +1,188 @@
import { afterEach, describe, expect, it } from "vitest";
import {
base64ToBytes,
bytesToBase64,
closeFrame,
createTerminalMux,
dataFrame,
muxUrlFromApiBase,
openFrame,
resizeFrame,
} from "./terminal-mux";
describe("terminal-mux framing", () => {
it("round-trips arbitrary (non-UTF8) bytes through base64", () => {
const bytes = new Uint8Array([0x00, 0x1b, 0x5b, 0xff, 0xfe, 0x7f, 0x41]);
expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes);
});
it("encodes an open frame the backend expects", () => {
expect(JSON.parse(openFrame("sess-1", 80, 24))).toEqual({
ch: "terminal",
type: "open",
id: "sess-1",
cols: 80,
rows: 24,
});
});
it("base64-encodes input bytes in a data frame", () => {
const frame = JSON.parse(dataFrame("sess-1", new TextEncoder().encode("ls\n")));
expect(frame).toMatchObject({ ch: "terminal", type: "data", id: "sess-1" });
expect(new TextDecoder().decode(base64ToBytes(frame.data))).toBe("ls\n");
});
it("carries cols/rows on resize and id on close", () => {
expect(JSON.parse(resizeFrame("s", 120, 40))).toMatchObject({ type: "resize", cols: 120, rows: 40 });
expect(JSON.parse(closeFrame("s"))).toEqual({ ch: "terminal", type: "close", id: "s" });
});
it("derives the ws mux url from the http api base (root path, not /api/v1)", () => {
expect(muxUrlFromApiBase("http://127.0.0.1:4317")).toBe("ws://127.0.0.1:4317/mux");
expect(muxUrlFromApiBase("https://host:8443/")).toBe("wss://host:8443/mux");
});
it("uses the current origin for a relative dev API base", () => {
expect(muxUrlFromApiBase("")).toBe("ws://localhost:3000/mux");
});
});
// Minimal fake socket so we can assert client behaviour without a live daemon.
class FakeSocket {
static OPEN = 1;
static instances: FakeSocket[] = [];
readyState = 0;
sent: string[] = [];
closed = false;
private listeners: Record<string, ((ev: unknown) => void)[]> = {};
constructor(public url: string) {
FakeSocket.instances.push(this);
}
addEventListener(type: string, cb: (ev: unknown) => void) {
(this.listeners[type] ??= []).push(cb);
}
send(frame: string) {
this.sent.push(frame);
}
close() {
this.closed = true;
}
emitOpen() {
this.readyState = FakeSocket.OPEN;
this.listeners.open?.forEach((cb) => cb({}));
}
emitMessage(data: string) {
this.listeners.message?.forEach((cb) => cb({ data }));
}
emitClose() {
this.listeners.close?.forEach((cb) => cb({}));
}
emitError() {
this.listeners.error?.forEach((cb) => cb({}));
}
}
describe("createTerminalMux client", () => {
afterEach(() => {
FakeSocket.instances = [];
});
it("queues frames until open, then flushes them in order", () => {
const mux = createTerminalMux("ws://x/mux", FakeSocket as unknown as typeof WebSocket);
const socket = FakeSocket.instances.at(-1)!;
mux.open("s", 80, 24);
expect(socket.sent).toHaveLength(0); // not open yet → queued
socket.emitOpen();
expect(JSON.parse(socket.sent[0])).toMatchObject({ type: "open", id: "s" });
});
it("routes server data frames to the matching id listener as bytes", () => {
const mux = createTerminalMux("ws://x/mux", FakeSocket as unknown as typeof WebSocket);
const socket = FakeSocket.instances.at(-1)!;
socket.emitOpen();
const chunks: string[] = [];
mux.onData("s", (bytes) => chunks.push(new TextDecoder().decode(bytes)));
socket.emitMessage(
JSON.stringify({ ch: "terminal", id: "s", type: "data", data: bytesToBase64(new TextEncoder().encode("hi")) }),
);
socket.emitMessage(
JSON.stringify({
ch: "terminal",
id: "other",
type: "data",
data: bytesToBase64(new TextEncoder().encode("nope")),
}),
);
expect(chunks).toEqual(["hi"]);
});
it("fires the exit listener on an exited frame", () => {
const mux = createTerminalMux("ws://x/mux", FakeSocket as unknown as typeof WebSocket);
const socket = FakeSocket.instances.at(-1)!;
socket.emitOpen();
let exited = false;
mux.onExit("s", () => {
exited = true;
});
socket.emitMessage(JSON.stringify({ ch: "terminal", id: "s", type: "exited" }));
expect(exited).toBe(true);
});
it("fires the opened listener on the server's open ack", () => {
const mux = createTerminalMux("ws://x/mux", FakeSocket as unknown as typeof WebSocket);
const socket = FakeSocket.instances.at(-1)!;
socket.emitOpen();
let opened = false;
mux.onOpened("s", () => {
opened = true;
});
socket.emitMessage(JSON.stringify({ ch: "terminal", id: "s", type: "opened" }));
expect(opened).toBe(true);
});
it("routes a pane error frame to that pane's error listener only", () => {
const mux = createTerminalMux("ws://x/mux", FakeSocket as unknown as typeof WebSocket);
const socket = FakeSocket.instances.at(-1)!;
socket.emitOpen();
const errors: string[] = [];
const otherErrors: string[] = [];
mux.onError("s", (message) => errors.push(message));
mux.onError("other", (message) => otherErrors.push(message));
socket.emitMessage(JSON.stringify({ ch: "terminal", id: "s", type: "error", error: "no such pane" }));
expect(errors).toEqual(["no such pane"]);
expect(otherErrors).toEqual([]);
});
it("broadcasts an id-less error frame to every error listener", () => {
const mux = createTerminalMux("ws://x/mux", FakeSocket as unknown as typeof WebSocket);
const socket = FakeSocket.instances.at(-1)!;
socket.emitOpen();
const seen: string[] = [];
mux.onError("a", (message) => seen.push(`a:${message}`));
mux.onError("b", (message) => seen.push(`b:${message}`));
socket.emitMessage(JSON.stringify({ ch: "terminal", type: "error", error: "missing terminal id" }));
expect(seen.sort()).toEqual(["a:missing terminal id", "b:missing terminal id"]);
});
it("reports connection transitions, deduping error+close into one closed", () => {
const mux = createTerminalMux("ws://x/mux", FakeSocket as unknown as typeof WebSocket);
const socket = FakeSocket.instances.at(-1)!;
const states: string[] = [];
mux.onConnectionChange((state) => states.push(state));
socket.emitOpen();
socket.emitError();
socket.emitClose();
expect(states).toEqual(["open", "closed"]);
});
it("does not report a connection change for its own dispose", () => {
const mux = createTerminalMux("ws://x/mux", FakeSocket as unknown as typeof WebSocket);
const socket = FakeSocket.instances.at(-1)!;
socket.emitOpen();
const states: string[] = [];
mux.onConnectionChange((state) => states.push(state));
mux.dispose();
socket.emitClose(); // browser fires close after socket.close()
expect(states).toEqual([]);
});
});

View File

@ -0,0 +1,231 @@
// WebSocket client for the daemon's terminal multiplexer (`/mux`).
//
// The wire protocol mirrors backend/internal/terminal/protocol.go: a single
// JSON-framed socket tagged by channel ("ch"). Terminal payloads carry the PTY
// bytes base64-encoded in `data` because PTY output is arbitrary bytes that a
// raw JSON string cannot represent.
//
// ch "terminal" — per-pane byte stream keyed by an opaque runtime handle id
// client → open{id,cols,rows} | data{id,data} | resize{id,cols,rows} | close{id}
// server → opened{id} | data{id,data} | exited{id} | error{id?,error}
// ch "system" — ping/pong liveness
//
// The renderer connects directly to the loopback daemon (same host/port as the
// REST API, path `/mux`); it is not proxied through the Electron main process.
type ServerFrame = {
ch: string;
id?: string;
type: string;
data?: string;
error?: string;
};
// ---- pure framing helpers (unit-tested in terminal-mux.test.ts) ----
export function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
export function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
export function openFrame(id: string, cols: number, rows: number): string {
return JSON.stringify({ ch: "terminal", type: "open", id, cols, rows });
}
export function dataFrame(id: string, bytes: Uint8Array): string {
return JSON.stringify({ ch: "terminal", type: "data", id, data: bytesToBase64(bytes) });
}
export function resizeFrame(id: string, cols: number, rows: number): string {
return JSON.stringify({ ch: "terminal", type: "resize", id, cols, rows });
}
export function closeFrame(id: string): string {
return JSON.stringify({ ch: "terminal", type: "close", id });
}
function pingFrame(): string {
return JSON.stringify({ ch: "system", type: "ping" });
}
// Derive the ws(s)://.../mux URL from the REST API base. The mux is mounted at
// the router root (backend router.go), not under /api/v1.
export function muxUrlFromApiBase(apiBaseUrl: string): string {
if (apiBaseUrl === "" && typeof window !== "undefined") {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/mux`;
}
// http://host → ws://host and https://host → wss://host (the trailing "s" is left
// in place by the anchored replace). apiBaseUrl is the host root (e.g.
// http://127.0.0.1:4317); strip any trailing slash before appending /mux.
const ws = apiBaseUrl.replace(/^http/i, "ws");
return `${ws.replace(/\/+$/, "")}/mux`;
}
type DataListener = (bytes: Uint8Array) => void;
type ExitListener = () => void;
type OpenedListener = () => void;
type ErrorListener = (message: string) => void;
export type MuxConnectionState = "open" | "closed";
type ConnectionListener = (state: MuxConnectionState) => void;
export type TerminalMux = {
/** Open a PTY pane for the given runtime/session id at an initial size. */
open: (id: string, cols: number, rows: number) => void;
/** Forward a keystroke string (xterm `onData`) to the pane. */
sendInput: (id: string, input: string) => void;
resize: (id: string, cols: number, rows: number) => void;
close: (id: string) => void;
onData: (id: string, listener: DataListener) => () => void;
onExit: (id: string, listener: ExitListener) => () => void;
/** Server ack that the pane is attached; the output replay follows it. */
onOpened: (id: string, listener: OpenedListener) => () => void;
/**
* Server `error` frames. A frame carrying a pane id reaches that pane's
* listeners; an id-less frame is connection-scoped and reaches every error
* listener.
*/
onError: (id: string, listener: ErrorListener) => () => void;
/** Socket-level state: "open" on connect, "closed" on close or socket error. */
onConnectionChange: (listener: ConnectionListener) => () => void;
/** Close the socket and drop all listeners. */
dispose: () => void;
};
const PING_INTERVAL_MS = 20_000;
function subscribeById<T>(map: Map<string, Set<T>>, id: string, listener: T): () => void {
const set = map.get(id) ?? new Set<T>();
set.add(listener);
map.set(id, set);
return () => set.delete(listener);
}
/**
* Create a mux client over a single WebSocket. Frames sent before the socket is
* OPEN are queued and flushed on connect. There is no auto-reconnect at this
* layer: a dropped socket is reported through onConnectionChange("closed") and
* the owner (useTerminalSession) decides whether to build a fresh client.
*/
export function createTerminalMux(url: string, WebSocketImpl: typeof WebSocket = WebSocket): TerminalMux {
const socket = new WebSocketImpl(url);
const encoder = new TextEncoder();
const queue: string[] = [];
const dataListeners = new Map<string, Set<DataListener>>();
const exitListeners = new Map<string, Set<ExitListener>>();
const openedListeners = new Map<string, Set<OpenedListener>>();
const errorListeners = new Map<string, Set<ErrorListener>>();
const connectionListeners = new Set<ConnectionListener>();
let connectionState: MuxConnectionState | undefined;
let pingTimer: ReturnType<typeof setInterval> | undefined;
let disposed = false;
// Dedupes transitions: a socket "error" event is typically followed by
// "close", and only the first should notify.
const setConnectionState = (next: MuxConnectionState) => {
if (disposed || connectionState === next) return;
connectionState = next;
connectionListeners.forEach((listener) => listener(next));
};
const flush = () => {
while (queue.length > 0) {
const frame = queue.shift();
if (frame !== undefined) socket.send(frame);
}
};
const send = (frame: string) => {
if (disposed) return;
if (socket.readyState === WebSocketImpl.OPEN) {
socket.send(frame);
} else {
queue.push(frame);
}
};
socket.addEventListener("open", () => {
if (disposed) return;
flush();
pingTimer = setInterval(() => send(pingFrame()), PING_INTERVAL_MS);
setConnectionState("open");
});
socket.addEventListener("close", () => setConnectionState("closed"));
socket.addEventListener("error", () => setConnectionState("closed"));
socket.addEventListener("message", (event: MessageEvent) => {
if (typeof event.data !== "string") return;
let frame: ServerFrame;
try {
frame = JSON.parse(event.data) as ServerFrame;
} catch {
return;
}
if (frame.ch !== "terminal") return;
if (frame.type === "error") {
const message = frame.error ?? "unknown terminal error";
if (frame.id !== undefined) {
errorListeners.get(frame.id)?.forEach((listener) => listener(message));
} else {
errorListeners.forEach((set) => set.forEach((listener) => listener(message)));
}
return;
}
if (frame.id === undefined) return;
if (frame.type === "data" && frame.data) {
dataListeners.get(frame.id)?.forEach((listener) => listener(base64ToBytes(frame.data as string)));
} else if (frame.type === "exited") {
exitListeners.get(frame.id)?.forEach((listener) => listener());
} else if (frame.type === "opened") {
openedListeners.get(frame.id)?.forEach((listener) => listener());
}
});
const dispose = () => {
if (disposed) return;
disposed = true;
if (pingTimer) clearInterval(pingTimer);
dataListeners.clear();
exitListeners.clear();
openedListeners.clear();
errorListeners.clear();
connectionListeners.clear();
try {
socket.close();
} catch {
// socket may already be closing; ignore.
}
};
return {
open: (id, cols, rows) => send(openFrame(id, cols, rows)),
sendInput: (id, input) => send(dataFrame(id, encoder.encode(input))),
resize: (id, cols, rows) => send(resizeFrame(id, cols, rows)),
close: (id) => send(closeFrame(id)),
onData: (id, listener) => subscribeById(dataListeners, id, listener),
onExit: (id, listener) => subscribeById(exitListeners, id, listener),
onOpened: (id, listener) => subscribeById(openedListeners, id, listener),
onError: (id, listener) => subscribeById(errorListeners, id, listener),
onConnectionChange: (listener) => {
connectionListeners.add(listener);
return () => connectionListeners.delete(listener);
},
dispose,
};
}

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,24 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import "@xterm/xterm/css/xterm.css";
import "./styles.css";
import { queryClient } from "./lib/query-client";
import { createAppRouter } from "./router";
const router = createAppRouter(queryClient);
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>,
);

View File

@ -0,0 +1,76 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file is auto-generated by @tanstack/router-plugin
// Do not edit this file manually — it is regenerated on every dev/build.
// Import Routes
import { Route as rootRoute } from "./routes/__root";
import { Route as IndexRoute } from "./routes/index";
import { Route as WorkspacesWorkspaceIdRoute } from "./routes/workspaces.$workspaceId";
import { Route as WorkspacesWorkspaceIdSessionsSessionIdRoute } from "./routes/workspaces.$workspaceId_.sessions.$sessionId";
// Create/Update Routes
const IndexRouteWithChildren = IndexRoute.update({
id: "/",
path: "/",
getParentRoute: () => rootRoute,
} as any);
const WorkspacesWorkspaceIdRouteWithChildren = WorkspacesWorkspaceIdRoute.update({
id: "/workspaces/$workspaceId",
path: "/workspaces/$workspaceId",
getParentRoute: () => rootRoute,
} as any);
const WorkspacesWorkspaceIdSessionsSessionIdRouteWithChildren = WorkspacesWorkspaceIdSessionsSessionIdRoute.update({
id: "/workspaces/$workspaceId_/sessions/$sessionId",
path: "/workspaces/$workspaceId/sessions/$sessionId",
getParentRoute: () => rootRoute,
} as any);
// Populate FileRoutes
export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
"/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute;
"/workspaces/$workspaceId/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
"/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute;
"/workspaces/$workspaceId/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
}
export interface FileRoutesById {
__root__: typeof rootRoute;
"/": typeof IndexRoute;
"/workspaces/$workspaceId": typeof WorkspacesWorkspaceIdRoute;
"/workspaces/$workspaceId_/sessions/$sessionId": typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId/sessions/$sessionId";
fileRoutesByTo: FileRoutesByTo;
to: "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId/sessions/$sessionId";
id: "__root__" | "/" | "/workspaces/$workspaceId" | "/workspaces/$workspaceId_/sessions/$sessionId";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute;
WorkspacesWorkspaceIdRoute: typeof WorkspacesWorkspaceIdRoute;
WorkspacesWorkspaceIdSessionsSessionIdRoute: typeof WorkspacesWorkspaceIdSessionsSessionIdRoute;
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRouteWithChildren,
WorkspacesWorkspaceIdRoute: WorkspacesWorkspaceIdRouteWithChildren,
WorkspacesWorkspaceIdSessionsSessionIdRoute: WorkspacesWorkspaceIdSessionsSessionIdRouteWithChildren,
};
export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileTypes<FileRouteTypes>();

View File

@ -0,0 +1,18 @@
import { createHashHistory, createRouter } from "@tanstack/react-router";
import type { QueryClient } from "@tanstack/react-query";
import { routeTree } from "./routeTree.gen";
// Hash history is required for Electron's file:// renderer origin — browser
// history would break on hard reload since there is no server to serve paths.
export function createAppRouter(queryClient: QueryClient) {
return createRouter({
history: createHashHistory(),
routeTree,
context: { queryClient },
defaultPreload: "intent",
// Always re-run loaders when a route is preloaded or visited so React
// Query's cache is the single source of truth for staleness.
defaultPreloadStaleTime: 0,
scrollRestoration: true,
});
}

View File

@ -0,0 +1,17 @@
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { TooltipProvider } from "../components/ui/tooltip";
import type { QueryClient } from "@tanstack/react-query";
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
}>()({
component: RootComponent,
});
function RootComponent() {
return (
<TooltipProvider>
<Outlet />
</TooltipProvider>
);
}

View File

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { App } from "../App";
export const Route = createFileRoute("/")({
component: App,
});

View File

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { App } from "../App";
export const Route = createFileRoute("/workspaces/$workspaceId")({
component: WorkspaceRoute,
});
function WorkspaceRoute() {
const { workspaceId } = Route.useParams();
return <App routeWorkspaceId={workspaceId} />;
}

View File

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { App } from "../App";
export const Route = createFileRoute("/workspaces/$workspaceId/sessions/$sessionId")({
component: SessionRoute,
});
function SessionRoute() {
const { workspaceId, sessionId } = Route.useParams();
return <App routeWorkspaceId={workspaceId} routeSessionId={sessionId} />;
}

View File

@ -0,0 +1,65 @@
import { create } from "zustand";
export type Theme = "light" | "dark";
/** Orchestrator-led: the app lands on the orchestrator; a worker row drills in. */
export type WorkbenchView = "orchestrator" | "session";
/** Worker topbar view toggles — Changes (Git rail) is the default. */
export type WorkbenchTab = "changes" | "files" | "terminal";
type UiState = {
view: WorkbenchView;
workbenchTab: WorkbenchTab;
isSidebarOpen: boolean;
selectedSessionId: string | null;
selectedWorkspaceId: string | null;
theme: Theme;
setWorkbenchTab: (tab: WorkbenchTab) => void;
setSystemTheme: (theme: Theme) => void;
toggleSidebar: () => void;
selectOrchestrator: () => void;
selectWorkspace: (workspaceId: string) => void;
selectSession: (sessionId: string, workspaceId?: string) => void;
};
const sidebarStorageKey = "ao.sidebar.open";
function getLocalStorage() {
if (typeof window === "undefined" || !window.localStorage) return null;
return window.localStorage;
}
function initialSidebarOpen() {
return getLocalStorage()?.getItem(sidebarStorageKey) !== "false";
}
function initialTheme(): Theme {
if (typeof window === "undefined") return "dark";
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
export const useUiStore = create<UiState>((set) => ({
view: "orchestrator",
workbenchTab: "changes",
isSidebarOpen: initialSidebarOpen(),
selectedSessionId: null,
selectedWorkspaceId: null,
theme: initialTheme(),
setWorkbenchTab: (workbenchTab) => set({ workbenchTab }),
setSystemTheme: (theme) => set({ theme }),
toggleSidebar: () =>
set((state) => {
const isSidebarOpen = !state.isSidebarOpen;
getLocalStorage()?.setItem(sidebarStorageKey, String(isSidebarOpen));
return { isSidebarOpen };
}),
selectOrchestrator: () => set({ view: "orchestrator" }),
selectWorkspace: (selectedWorkspaceId) => set({ selectedWorkspaceId }),
selectSession: (selectedSessionId, workspaceId) =>
set((state) => ({
selectedSessionId,
selectedWorkspaceId: workspaceId ?? state.selectedWorkspaceId,
view: "session",
workbenchTab: "changes",
})),
}));

View File

@ -0,0 +1,181 @@
@import "tailwindcss";
/*
* Design system see DESIGN.md (source of truth). emdash-matched: flat near-black
* neutral ramp, hairline borders, system font, refined-blue accent (not emdash's
* jade). Dark is primary (:root); light is the supported override
* (:root[data-theme="light"]). The Zustand theme toggle sets data-theme on <html>.
*/
@theme inline {
--font-sans:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Helvetica Neue",
sans-serif;
--font-mono: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* Surfaces */
--color-background: var(--bg);
--color-foreground: var(--fg);
--color-surface: var(--bg-1);
--color-card: var(--bg-1);
--color-raised: var(--bg-2);
--color-overlay: var(--bg-3);
--color-popover: var(--bg-1);
--color-popover-foreground: var(--fg);
--color-sidebar: var(--bg);
--color-sidebar-foreground: var(--fg-muted);
--color-terminal: var(--term-bg);
/* Text */
--color-muted: var(--bg-2);
--color-muted-foreground: var(--fg-muted);
--color-passive: var(--fg-passive);
--color-secondary: var(--bg-2);
--color-secondary-foreground: var(--fg-muted);
/* Accent (refined blue) — primary buttons, active session, focus */
--color-primary: var(--accent);
--color-primary-foreground: var(--accent-fg);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-fg);
--color-accent-weak: var(--accent-weak);
--color-accent-dim: var(--accent-dim);
--color-ring: var(--accent);
/* Borders */
--color-border: var(--border);
--color-border-strong: var(--border-1);
/* Status — map 1:1 to the daemon's derived statuses */
--color-success: var(--green);
--color-success-strong: var(--green-strong);
--color-warning: var(--amber);
--color-error: var(--red);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--animate-status-pulse: status-pulse 1.8s ease-in-out infinite;
--animate-overlay-in: overlay-in 150ms ease-out;
--animate-modal-in: modal-in 150ms ease-out;
}
:root {
/* Dark — primary. sRGB approximations of emdash's display-p3 neutral ramp. */
--bg: #111111;
--bg-1: #191919;
--bg-2: #222222;
--bg-3: #2a2a2a;
--fg: #eeeeee;
--fg-muted: #b4b4b4;
--fg-passive: #6e6e6e;
--border: #3a3a3a;
--border-1: #484848;
--accent: #5b9dff;
--accent-fg: #0b0d12;
--accent-weak: rgb(91 157 255 / 0.16);
--accent-dim: #2a4a7a;
--green: #6cb16c;
--green-strong: #7bd88f;
--amber: #ffcc4a;
--red: #d4544f;
--term-bg: #161616;
--term-fg: #d7d7d2;
--term-green: #7bd88f;
--term-dim: #7c7c7c;
--term-blue: #5b9dff;
--term-amber: #ffcc4a;
--shadow: 0 1px 0 rgb(0 0 0 / 0.4), 0 12px 32px rgb(0 0 0 / 0.45);
}
:root[data-theme="light"] {
/* Light — supported, not primary. */
--bg: #fcfcfc;
--bg-1: #ffffff;
--bg-2: #ededee;
--bg-3: #e4e4e6;
--fg: #1a1a1a;
--fg-muted: #666666;
--fg-passive: #9a9a9a;
--border: #e3e3e5;
--border-1: #d4d4d6;
--accent: #2563eb;
--accent-fg: #ffffff;
--accent-weak: rgb(37 99 235 / 0.12);
--accent-dim: #bcd2f7;
--green: #1a7f37;
--green-strong: #1a7f37;
--amber: #9a6b00;
--red: #c0392b;
/* Terminal keeps its own dark palette in both themes (it is the agent CLI). */
--term-bg: #161616;
--term-fg: #d7d7d2;
--term-green: #7bd88f;
--term-dim: #7c7c7c;
--term-blue: #5b9dff;
--term-amber: #ffcc4a;
--shadow: 0 1px 0 rgb(0 0 0 / 0.03), 0 12px 30px rgb(20 30 50 / 0.1);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
overflow: hidden;
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
button,
input,
textarea,
select {
font: inherit;
}
.xterm {
height: 100%;
}
@keyframes status-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@keyframes overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-in {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}

View File

@ -0,0 +1,59 @@
import "@testing-library/jest-dom/vitest";
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
Object.defineProperty(window, "ResizeObserver", {
configurable: true,
writable: true,
value: ResizeObserverStub,
});
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}),
});
const localStorageStub = (() => {
const values = new Map<string, string>();
return {
clear: () => values.clear(),
getItem: (key: string) => values.get(key) ?? null,
removeItem: (key: string) => values.delete(key),
setItem: (key: string, value: string) => values.set(key, value),
};
})();
Object.defineProperty(window, "localStorage", {
configurable: true,
writable: true,
value: localStorageStub,
});
HTMLCanvasElement.prototype.getContext = (() => ({})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
window.ao = {
app: {
getVersion: async () => "0.0.0-test",
chooseDirectory: async () => null,
},
daemon: {
getStatus: async () => ({ state: "stopped" }),
start: async () => ({ state: "starting" }),
stop: async () => ({ state: "stopped" }),
onStatus: () => () => undefined,
},
};

View File

@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
sessionIsActive,
sessionNeedsAttention,
toAgentProvider,
toSessionStatus,
workerDisplayStatus,
workerStatusPulses,
type WorkspaceSession,
} from "./workspace";
function sessionWith(overrides: Partial<WorkspaceSession>): WorkspaceSession {
return {
id: "sess-1",
workspaceId: "ws-1",
workspaceName: "my-app",
title: "fix-bug",
provider: "claude-code",
branch: "feat/x",
status: "working",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("toSessionStatus", () => {
it("passes through a known status", () => {
expect(toSessionStatus("mergeable")).toBe("mergeable");
});
it("overrides to terminated when the session is terminated", () => {
expect(toSessionStatus("working", true)).toBe("terminated");
});
it("falls back to working for an unknown status", () => {
expect(toSessionStatus("bogus")).toBe("working");
});
it("falls back to working when status is undefined", () => {
expect(toSessionStatus(undefined)).toBe("working");
});
});
describe("workerDisplayStatus", () => {
it("prefers an explicit displayStatus override", () => {
expect(workerDisplayStatus(sessionWith({ status: "ci_failed", displayStatus: "done" }))).toBe("done");
});
it.each([
["needs_input", "needs_you"],
["changes_requested", "needs_you"],
["review_pending", "needs_you"],
["ci_failed", "ci_failed"],
["approved", "mergeable"],
["mergeable", "mergeable"],
["merged", "done"],
["terminated", "done"],
["working", "working"],
["idle", "working"],
] as const)("maps %s to %s", (status, expected) => {
expect(workerDisplayStatus(sessionWith({ status }))).toBe(expected);
});
});
describe("sessionIsActive", () => {
it("is false for merged and terminated", () => {
expect(sessionIsActive(sessionWith({ status: "merged" }))).toBe(false);
expect(sessionIsActive(sessionWith({ status: "terminated" }))).toBe(false);
});
it("is true for in-progress statuses", () => {
expect(sessionIsActive(sessionWith({ status: "working" }))).toBe(true);
expect(sessionIsActive(sessionWith({ status: "pr_open" }))).toBe(true);
});
});
describe("sessionNeedsAttention", () => {
it.each(["needs_input", "changes_requested", "review_pending", "ci_failed"] as const)("is true for %s", (status) => {
expect(sessionNeedsAttention(sessionWith({ status }))).toBe(true);
});
it("is false for statuses that don't need the user", () => {
expect(sessionNeedsAttention(sessionWith({ status: "working" }))).toBe(false);
expect(sessionNeedsAttention(sessionWith({ status: "mergeable" }))).toBe(false);
});
});
describe("workerStatusPulses", () => {
it("pulses only for working and needs_you", () => {
expect(workerStatusPulses("working")).toBe(true);
expect(workerStatusPulses("needs_you")).toBe(true);
expect(workerStatusPulses("mergeable")).toBe(false);
expect(workerStatusPulses("done")).toBe(false);
});
});
describe("toAgentProvider", () => {
it("passes through a known provider", () => {
expect(toAgentProvider("opencode")).toBe("opencode");
});
it("defaults unknown and undefined providers to codex", () => {
expect(toAgentProvider("totally-unknown")).toBe("codex");
expect(toAgentProvider(undefined)).toBe("codex");
});
});

View File

@ -0,0 +1,187 @@
export type SessionStatus =
| "working"
| "pr_open"
| "draft"
| "ci_failed"
| "review_pending"
| "changes_requested"
| "approved"
| "mergeable"
| "merged"
| "needs_input"
| "idle"
| "terminated";
const sessionStatuses = new Set<SessionStatus>([
"working",
"pr_open",
"draft",
"ci_failed",
"review_pending",
"changes_requested",
"approved",
"mergeable",
"merged",
"needs_input",
"idle",
"terminated",
]);
export function toSessionStatus(status?: string, isTerminated = false): SessionStatus {
if (isTerminated) return "terminated";
return status && sessionStatuses.has(status as SessionStatus) ? (status as SessionStatus) : "working";
}
export type AgentProvider =
| "codex"
| "claude-code"
| "opencode"
| "aider"
| "grok"
| "droid"
| "amp"
| "agy"
| "crush"
| "cursor"
| "qwen"
| "copilot"
| "goose"
| "auggie"
| "continue"
| "devin"
| "cline"
| "kimi"
| "kiro"
| "kilocode"
| "vibe"
| "pi"
| "autohand";
/** A file in a worker's worktree diff (drives the Git review rail). */
export type ChangedFile = {
path: string;
additions: number;
deletions: number;
staged?: boolean;
};
export type WorkspaceSession = {
id: string;
terminalHandleId?: string;
workspaceId: string;
workspaceName: string;
title: string;
provider: AgentProvider;
branch: string;
status: SessionStatus;
updatedAt: string;
/** The session's git diff against its base, when known. */
changedFiles?: ChangedFile[];
/** Pre-filled commit subject for the Git rail, when known. */
commitMessage?: string;
pullRequest?: {
number: number;
state: "open" | "draft" | "merged" | "closed";
};
/**
* Display status as derived by the daemon at read time. Optional override; when
* absent it is derived from {@link SessionStatus} via {@link workerDisplayStatus}.
*/
displayStatus?: WorkerDisplayStatus;
};
/** Glanceable worker status. Maps 1:1 to the accent colors in DESIGN.md. */
export type WorkerDisplayStatus = "working" | "needs_you" | "mergeable" | "ci_failed" | "done";
export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplayStatus {
if (session.displayStatus) return session.displayStatus;
switch (session.status) {
case "needs_input":
case "changes_requested":
case "review_pending":
return "needs_you";
case "ci_failed":
return "ci_failed";
case "approved":
case "mergeable":
return "mergeable";
case "merged":
case "terminated":
return "done";
default:
return "working";
}
}
export function sessionIsActive(session: WorkspaceSession): boolean {
return session.status !== "merged" && session.status !== "terminated";
}
export function sessionNeedsAttention(session: WorkspaceSession): boolean {
return (
session.status === "needs_input" ||
session.status === "changes_requested" ||
session.status === "review_pending" ||
session.status === "ci_failed"
);
}
export const workerStatusLabel: Record<WorkerDisplayStatus, string> = {
working: "working",
needs_you: "needs you",
mergeable: "mergeable",
ci_failed: "ci failed",
done: "done",
};
/** Whether a status should breathe (alive/working). */
export function workerStatusPulses(status: WorkerDisplayStatus): boolean {
return status === "working" || status === "needs_you";
}
export type WorkspaceSummary = {
id: string;
name: string;
path: string;
type?: "main" | "worktree";
accentColor?: string;
diff?: {
additions: number;
deletions: number;
};
pullRequest?: {
number: number;
state: "open" | "draft" | "merged" | "closed";
};
sessions: WorkspaceSession[];
};
export function toAgentProvider(provider?: string): AgentProvider {
switch (provider) {
case "claude-code":
case "opencode":
case "aider":
case "grok":
case "droid":
case "amp":
case "agy":
case "crush":
case "cursor":
case "qwen":
case "copilot":
case "goose":
case "auggie":
case "continue":
case "devin":
case "cline":
case "kimi":
case "kiro":
case "kilocode":
case "vibe":
case "pi":
case "autohand":
return provider;
default:
return "codex";
}
}

View File

@ -0,0 +1,118 @@
import { describe, expect, it, vi } from "vitest";
import { createListenPortScanner, defaultRunFilePath, parseDaemonListenPort, parseRunFile } from "./daemon-discovery";
// Real shape emitted by slog's TextHandler in backend/internal/httpd/server.go.
const LISTEN_LINE = 'time=2026-06-10T09:15:04.221-07:00 level=INFO msg="daemon listening" addr=127.0.0.1:3001 pid=4242';
describe("parseDaemonListenPort", () => {
it("extracts the bound port from the slog listen line", () => {
expect(parseDaemonListenPort(LISTEN_LINE)).toBe(3001);
});
it("reports a fallback port that differs from the configured one", () => {
expect(parseDaemonListenPort(LISTEN_LINE.replace("3001", "3037"))).toBe(3037);
});
it("handles IPv6 listen addresses", () => {
expect(parseDaemonListenPort('level=INFO msg="daemon listening" addr=[::1]:4317 pid=1')).toBe(4317);
});
it("ignores other log lines, even ones carrying an addr attribute", () => {
expect(parseDaemonListenPort('level=INFO msg="proxy dial" addr=127.0.0.1:9999')).toBeNull();
expect(parseDaemonListenPort("plain stdout chatter")).toBeNull();
});
it("returns null when the listen line has no usable addr", () => {
expect(parseDaemonListenPort('level=INFO msg="daemon listening" pid=4242')).toBeNull();
expect(parseDaemonListenPort('level=INFO msg="daemon listening" addr=garbage')).toBeNull();
expect(parseDaemonListenPort('level=INFO msg="daemon listening" addr=127.0.0.1:99999')).toBeNull();
});
});
describe("createListenPortScanner", () => {
it("reassembles a line split across chunks", () => {
const onPort = vi.fn();
const scan = createListenPortScanner(onPort);
scan('level=INFO msg="daemon lis');
expect(onPort).not.toHaveBeenCalled();
scan('tening" addr=127.0.0.1:3001 pid=4242\n');
expect(onPort).toHaveBeenCalledWith(3001);
});
it("reports only the first announcement", () => {
const onPort = vi.fn();
const scan = createListenPortScanner(onPort);
scan(`noise line\n${LISTEN_LINE}\n${LISTEN_LINE.replace("3001", "4000")}\n`);
scan(`${LISTEN_LINE.replace("3001", "5000")}\n`);
expect(onPort).toHaveBeenCalledTimes(1);
expect(onPort).toHaveBeenCalledWith(3001);
});
it("stays quiet on streams without the announcement", () => {
const onPort = vi.fn();
const scan = createListenPortScanner(onPort);
scan("starting up\nmigrations applied\n");
expect(onPort).not.toHaveBeenCalled();
});
});
describe("parseRunFile", () => {
const valid = JSON.stringify({ pid: 4242, port: 3037, startedAt: "2026-06-10T16:15:04Z" });
it("parses a valid handshake", () => {
expect(parseRunFile(valid)).toEqual({
pid: 4242,
port: 3037,
startedAtMs: Date.parse("2026-06-10T16:15:04Z"),
});
});
it("returns null for malformed JSON", () => {
expect(parseRunFile("{not json")).toBeNull();
expect(parseRunFile("")).toBeNull();
expect(parseRunFile("null")).toBeNull();
});
it("returns null for a missing or invalid port", () => {
expect(parseRunFile(JSON.stringify({ pid: 1, startedAt: "2026-06-10T16:15:04Z" }))).toBeNull();
expect(parseRunFile(JSON.stringify({ pid: 1, port: 0 }))).toBeNull();
expect(parseRunFile(JSON.stringify({ pid: 1, port: 70000 }))).toBeNull();
expect(parseRunFile(JSON.stringify({ pid: 1, port: "3001" }))).toBeNull();
});
it("treats a missing or unparseable startedAt as epoch zero", () => {
expect(parseRunFile(JSON.stringify({ pid: 1, port: 3001 }))?.startedAtMs).toBe(0);
expect(parseRunFile(JSON.stringify({ pid: 1, port: 3001, startedAt: "not-a-date" }))?.startedAtMs).toBe(0);
});
});
describe("defaultRunFilePath", () => {
it("mirrors Go os.UserConfigDir on macOS", () => {
expect(defaultRunFilePath("darwin", {}, "/Users/me")).toBe(
"/Users/me/Library/Application Support/agent-orchestrator/running.json",
);
});
it("prefers XDG_CONFIG_HOME on linux, falling back to ~/.config", () => {
expect(defaultRunFilePath("linux", { XDG_CONFIG_HOME: "/xdg" }, "/home/me")).toBe(
"/xdg/agent-orchestrator/running.json",
);
expect(defaultRunFilePath("linux", {}, "/home/me")).toBe("/home/me/.config/agent-orchestrator/running.json");
});
it("uses APPDATA on windows and returns null when it is unset", () => {
expect(defaultRunFilePath("win32", { APPDATA: "C:\\Users\\me\\AppData\\Roaming" }, "C:\\Users\\me")).toContain(
"agent-orchestrator",
);
expect(defaultRunFilePath("win32", {}, "C:\\Users\\me")).toBeNull();
});
it("returns null when no config root can be resolved", () => {
expect(defaultRunFilePath("linux", {}, "")).toBeNull();
expect(defaultRunFilePath("darwin", {}, "")).toBeNull();
});
});

View File

@ -0,0 +1,112 @@
// Helpers for discovering the daemon's actually-bound port. The configured
// AO_PORT is only a request — the daemon may bind a different port (port 0,
// operator overrides), so the supervisor trusts what the daemon reports:
// - the slog text line `msg="daemon listening" addr=127.0.0.1:<port>`
// (backend/internal/httpd/server.go, written to stderr), and
// - the running.json handshake file (backend/internal/runfile).
// These functions are kept side-effect free and dependency-free (no node:*
// imports — vite-plugin-electron-renderer's polyfill breaks them under vitest)
// so tests can exercise them directly; the Electron main process owns the
// streams, fs polling, and timers.
// Minimal join: "/" works for fs access on every platform Node supports,
// including Windows paths that already contain backslashes (e.g. %APPDATA%).
function joinPath(...segments: string[]): string {
return segments.map((segment) => segment.replace(/[/\\]+$/, "")).join("/");
}
/**
* Parse one daemon log line for the listen announcement. slog's TextHandler
* emits `time=… level=INFO msg="daemon listening" addr=127.0.0.1:3001 pid=…`;
* the addr value never contains spaces, so it is unquoted. Returns the bound
* port, or null when the line is not the announcement.
*/
export function parseDaemonListenPort(line: string): number | null {
if (!line.includes('msg="daemon listening"')) return null;
const addr = /(?:^|\s)addr=("?)([^"\s]+)\1/.exec(line)?.[2];
if (!addr) return null;
return portFromAddr(addr);
}
// Take the segment after the last ":" so IPv6 literals like [::1]:3001 parse too.
function portFromAddr(addr: string): number | null {
const separator = addr.lastIndexOf(":");
if (separator === -1) return null;
const port = Number(addr.slice(separator + 1));
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
}
/**
* Incrementally scan a stdio stream for the listen announcement. Returns a
* chunk consumer that line-buffers (chunks can split a line anywhere) and
* invokes onPort exactly once, for the first announcement seen.
*/
export function createListenPortScanner(onPort: (port: number) => void): (chunk: string) => void {
let pending = "";
let done = false;
return (chunk) => {
if (done) return;
pending += chunk;
const lines = pending.split("\n");
pending = lines.pop() ?? "";
for (const line of lines) {
const port = parseDaemonListenPort(line);
if (port !== null) {
done = true;
onPort(port);
return;
}
}
};
}
/** Parsed running.json handshake — see backend/internal/runfile.Info. */
export type RunFileInfo = {
pid: number;
port: number;
/** startedAt in epoch ms; 0 when missing/unparseable. */
startedAtMs: number;
};
/** Parse running.json contents. Returns null for malformed JSON or an invalid port. */
export function parseRunFile(contents: string): RunFileInfo | null {
let raw: unknown;
try {
raw = JSON.parse(contents);
} catch {
return null;
}
if (typeof raw !== "object" || raw === null) return null;
const { pid, port, startedAt } = raw as { pid?: unknown; port?: unknown; startedAt?: unknown };
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) return null;
const startedAtMs = typeof startedAt === "string" ? Date.parse(startedAt) : NaN;
return {
pid: typeof pid === "number" && Number.isInteger(pid) ? pid : 0,
port,
startedAtMs: Number.isNaN(startedAtMs) ? 0 : startedAtMs,
};
}
/**
* Where the daemon writes running.json when AO_RUN_FILE is unset. Mirrors Go's
* os.UserConfigDir() + "agent-orchestrator/running.json" resolution in
* backend/internal/config so the supervisor reads the same file the daemon
* writes. Returns null when the platform's config root cannot be resolved.
*/
export function defaultRunFilePath(
platform: NodeJS.Platform,
env: Record<string, string | undefined>,
homeDir: string,
): string | null {
if (platform === "darwin") {
if (!homeDir) return null;
return joinPath(homeDir, "Library", "Application Support", "agent-orchestrator", "running.json");
}
if (platform === "win32") {
if (!env.APPDATA) return null;
return joinPath(env.APPDATA, "agent-orchestrator", "running.json");
}
const configRoot = env.XDG_CONFIG_HOME || (homeDir ? joinPath(homeDir, ".config") : "");
if (!configRoot) return null;
return joinPath(configRoot, "agent-orchestrator", "running.json");
}

View File

@ -0,0 +1,8 @@
// DaemonStatus is the supervisor → renderer handshake payload, shared by the
// Electron main process (which derives it) and the preload bridge (which types
// the IPC surface). The renderer picks it up through the preload's AoBridge type.
export type DaemonStatus = {
state: "starting" | "ready" | "stopped" | "error";
port?: number;
message?: string;
};

View File

@ -1,21 +1,32 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "CommonJS", "useDefineForClassFields": true,
"moduleResolution": "Node", "module": "ESNext",
"lib": ["ES2022", "DOM"], "moduleResolution": "Bundler",
"outDir": "dist", "lib": ["ES2022", "DOM", "DOM.Iterable"],
"rootDir": "src", "jsx": "react-jsx",
"types": ["vite/client", "vitest/globals"],
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"noEmit": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src/**/*.ts"], "include": [
"exclude": ["node_modules", "dist"] "src/**/*.ts",
"src/**/*.tsx",
"forge.config.ts",
"vite.main.config.ts",
"vite.preload.config.ts",
"vite.renderer.config.ts",
"playwright.config.ts"
],
"exclude": ["node_modules", "dist", "dist-electron", ".vite", "release", "out", "src/landing"]
} }

View File

@ -0,0 +1,5 @@
import { defineConfig } from "vite";
// Forge's VitePlugin handles all main-process build configuration.
// Add overrides here only if needed (e.g. custom externals or aliases).
export default defineConfig({});

View File

@ -0,0 +1,4 @@
import { defineConfig } from "vite";
// Forge's VitePlugin handles all preload script build configuration.
export default defineConfig({});

View File

@ -0,0 +1,71 @@
import { defineConfig } from "vite";
import type { Plugin } from "vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// CSP for the built renderer. The daemon is loopback-only, so network access is
// pinned to 127.0.0.1 (REST + SSE over http, terminal mux over ws). Injected at
// build time rather than written into index.html because the dev server needs
// inline scripts (react-refresh preamble) that a static meta tag would block.
const CONTENT_SECURITY_POLICY = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self' data:",
"connect-src 'self' http://127.0.0.1:* ws://127.0.0.1:*",
"object-src 'none'",
"base-uri 'self'",
"frame-src 'none'",
].join("; ");
const injectCspMeta: Plugin = {
name: "inject-csp-meta",
apply: "build",
transformIndexHtml() {
return [
{
tag: "meta",
attrs: { "http-equiv": "Content-Security-Policy", content: CONTENT_SECURITY_POLICY },
injectTo: "head-prepend",
},
];
},
};
export default defineConfig({
// Dev proxy for VITE_NO_ELECTRON=1 browser preview — forwards /api and /mux
// to the daemon so the renderer can be tested against a running daemon from
// a plain browser without an Electron shell.
server: {
proxy: {
"/api": {
target: process.env.AO_DEV_API_TARGET ?? "http://127.0.0.1:3001",
changeOrigin: false,
},
"/mux": {
target: process.env.AO_DEV_API_TARGET ?? "http://127.0.0.1:3001",
changeOrigin: false,
ws: true,
},
},
},
plugins: [
TanStackRouterVite({
routesDirectory: "./src/renderer/routes",
generatedRouteTree: "./src/renderer/routeTree.gen.ts",
target: "react",
autoCodeSplitting: true,
}),
react(),
tailwindcss(),
injectCspMeta,
],
test: {
environment: "jsdom",
exclude: ["node_modules/**", "dist/**", "dist-electron/**", "e2e/**"],
globals: true,
setupFiles: "./src/renderer/test/setup.ts",
},
});