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:
parent
5982051651
commit
c2c4404c7d
|
|
@ -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 }}
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 1–1.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` 15–16px): 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. |
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { AoBridge } from "../preload";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
ao?: AoBridge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 10_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
@ -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>,
|
||||||
|
);
|
||||||
|
|
@ -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>();
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { App } from "../App";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
component: App,
|
||||||
|
});
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({});
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
// Forge's VitePlugin handles all preload script build configuration.
|
||||||
|
export default defineConfig({});
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue