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)
|
||||
.envrc.local
|
||||
|
||||
# electron-forge / vite build output
|
||||
.vite/
|
||||
dist-electron/
|
||||
|
|
|
|||
|
|
@ -1,3 +1,11 @@
|
|||
# CLAUDE.md
|
||||
|
||||
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"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -33,6 +34,18 @@ const (
|
|||
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
|
||||
// built by Load.
|
||||
type Config struct {
|
||||
|
|
@ -54,6 +67,9 @@ type Config struct {
|
|||
// Manager (see DefaultAgent). Selected by AO_AGENT; startSession fails fast
|
||||
// if no adapter with this id is registered.
|
||||
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
|
||||
|
|
@ -74,6 +90,7 @@ func (c Config) Addr() string {
|
|||
// AO_RUN_FILE running.json path (default <state-dir>/running.json)
|
||||
// AO_DATA_DIR durable state dir (default <state-dir>/data)
|
||||
// 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.
|
||||
func Load() (Config, error) {
|
||||
|
|
@ -83,6 +100,7 @@ func Load() (Config, error) {
|
|||
RequestTimeout: DefaultRequestTimeout,
|
||||
ShutdownTimeout: DefaultShutdownTimeout,
|
||||
Agent: DefaultAgent,
|
||||
AllowedOrigins: DefaultAllowedOrigins,
|
||||
}
|
||||
|
||||
if raw := os.Getenv("AO_PORT"); raw != "" {
|
||||
|
|
@ -116,6 +134,25 @@ func Load() (Config, error) {
|
|||
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()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ func TestLoadInvalid(t *testing.T) {
|
|||
{"negative request timeout", map[string]string{"AO_REQUEST_TIMEOUT": "-1s"}},
|
||||
{"zero shutdown timeout", map[string]string{"AO_SHUTDOWN_TIMEOUT": "0s"}},
|
||||
{"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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,4 +54,5 @@ type SessionRecord struct {
|
|||
type Session struct {
|
||||
SessionRecord
|
||||
Status SessionStatus `json:"status"`
|
||||
TerminalHandleID string `json:"terminalHandleId,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1203,6 +1203,8 @@ components:
|
|||
type: string
|
||||
name:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
resolveError:
|
||||
type: string
|
||||
sessionPrefix:
|
||||
|
|
@ -1210,6 +1212,7 @@ components:
|
|||
required:
|
||||
- id
|
||||
- name
|
||||
- path
|
||||
- sessionPrefix
|
||||
type: object
|
||||
RemoveProjectResult:
|
||||
|
|
@ -1332,6 +1335,8 @@ components:
|
|||
type: string
|
||||
status:
|
||||
type: string
|
||||
terminalHandleId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
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) {
|
||||
|
|
@ -407,6 +416,8 @@ type projectSummary struct {
|
|||
|
||||
Name string `json:"name"`
|
||||
|
||||
Path string `json:"path"`
|
||||
|
||||
SessionPrefix string `json:"sessionPrefix"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ type fakeSessionService struct {
|
|||
|
||||
func newFakeSessionService() *fakeSessionService {
|
||||
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}}
|
||||
}
|
||||
|
||||
|
|
@ -174,7 +174,7 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) {
|
|||
Sessions []sessionBody `json:"sessions"`
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -366,6 +366,7 @@ type sessionBody struct {
|
|||
Harness string `json:"harness"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Status string `json:"status"`
|
||||
TerminalHandleID string `json:"terminalHandleId"`
|
||||
}
|
||||
|
||||
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
|
||||
// requestLogger → slog-backed access log, stderr, carries the request id
|
||||
// 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
|
||||
// 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(requestLogger(log))
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(corsMiddleware(cfg.AllowedOrigins))
|
||||
|
||||
// JSON envelopes for unmatched routes / methods — chi's defaults are
|
||||
// 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{
|
||||
ID: domain.ProjectID(row.ID),
|
||||
Name: displayName(row),
|
||||
Path: row.Path,
|
||||
SessionPrefix: resolveSessionPrefix(row),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import "github.com/aoagents/agent-orchestrator/backend/internal/domain"
|
|||
type Summary struct {
|
||||
ID domain.ProjectID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
SessionPrefix string `json:"sessionPrefix"`
|
||||
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)
|
||||
}
|
||||
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",
|
||||
"private": true,
|
||||
"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": {
|
||||
"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",
|
||||
"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": {
|
||||
"@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",
|
||||
"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: {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
resolveError?: string;
|
||||
sessionPrefix: string;
|
||||
};
|
||||
|
|
@ -476,6 +477,7 @@ export interface components {
|
|||
kind: string;
|
||||
projectId: string;
|
||||
status: string;
|
||||
terminalHandleId?: string;
|
||||
/** Format: date-time */
|
||||
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 {
|
||||
const window = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1320,
|
||||
height: 860,
|
||||
minWidth: 960,
|
||||
minHeight: 640,
|
||||
title: "Agent Orchestrator",
|
||||
backgroundColor: "#0f1014",
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 14, y: 14 },
|
||||
webPreferences: {
|
||||
preload: preloadPath(),
|
||||
contextIsolation: true,
|
||||
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(() => {
|
||||
registerRendererProtocol();
|
||||
createWindow();
|
||||
initAutoUpdates();
|
||||
|
||||
app.on("activate", () => {
|
||||
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", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
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": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": [
|
||||
"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