merge fork, 25 May - 2 June (#2086)

* feat(core): add prs[] array to Session for multi-PR support — Phase 1 metadata layer

* feat(core): update lifecycle-manager for multi-PR per session — Phase 2

* feat(web): add prs[] to DashboardSession and wire up multi-PR display — Phase 3

* feat(plugins): append to prs metadata field on gh pr create — Phase 4

* fix: address all code review issues — PR number, enrichment pipeline, claimPR append, stale PRs, V1 compat, SCM calls, dead code, isDraft, session validation

* fix: address Greptile review — state machine aggregation, URL dedup, React keys, review comments, typecheck

* fix: remove useless assignment in lifecycle-manager — lint error

* fix: add missing prs field to Session objects in cli and plugin test files

* fix: add prs field to integration-tests helpers and fix V1 flat-file fallback in agent-claude-code

* fix: use stack approach for claimPR prs field — claimed PR becomes primary

* feat(core,web): multi-PR per session — enrichment, status dots, bug fixes (#1821)

- Session metadata now stores a comma-separated prs list; all PR URLs
  parsed into session.prs[] on load, backwards-compatible with single-PR
- Enrichment loop hydrates every PR in the list each poll cycle via the
  existing batch GraphQL call
- Fixed closed-PR filter reading from session lifecycle state instead of
  per-PR enrichment cache, which misclassified secondary PRs
- Fixed isDraft never propagating from enrichment cache to dashboard.pr
  and dashboard.prs[i]
- Fixed prs[0] and dashboard.pr being separate objects — enrichment on
  one now syncs to the other
- SessionCard PR badges now show a colored status dot per PR: green (CI
  passing/merged), yellow (CI pending), red (CI failing/changes
  requested), grey (unenriched/draft/closed)
- Corrected color token mapping: passing uses --color-status-merge
  (green), pending uses --color-status-pending (yellow)

* fix(core): add missing prs field to code-review-manager test fixture

* feat(web): per-PR rows with colored chips, titles, and click-to-switch on session card

- Replace single-PR badges with one row per PR when session has multiple PRs
- Each row shows: status-colored chip, PR title (truncated), diff size
- Chip colors: green (CI passing/merged), yellow (CI pending), red (CI
  failing/changes requested), grey (closed/draft/unenriched)
- Repo initials badge (e.g. AO, CM) shown per row when PRs span multiple repos
- Clicking a row selects it — description, alerts, and action buttons in the
  card body all switch to that PR's context
- Clicking the chip opens GitHub; clicking title/diff area selects without
  navigating away
- Single-PR sessions are completely unchanged

* fix(web): address multi-PR card review — effectivePR for merge button, color-mix chips, left-border selection

* fix(web): derive prs from pr in makeSession test helper

* fix(core): fall back to raw[pr] when prs absent in claimPR

* feat(web): PR switcher in session detail header for multi-PR sessions

* fix(core): treat ciStatus/reviewDecision 'none' as passing in multi-PR aggregation

* fix(core): stale-session cleanup checks all prs before killing multi-PR session

* fix(core): all-none reviewDecision aggregates to none, not approved

* fix(core): prs accumulation in Node hook, bump WRAPPER_VERSION, clear stale enrichment on claimPR

* fix(core,web): webhook matching for secondary PRs, restore lifecycle PR number fallback

* fix(web): merge endpoint resolves target PR from session.prs for secondary PRs

* fix(core,web): clamp selectedPRIndex, guard multi-PR partial cache miss, clear prs on takeover

* fix(core,web): scope webhook secondary PR match by owner/repo, handle JSON metadata in Node hook

* fix(web): guard merge route PR lookup by owner/repo to prevent number collision

* fix(web): forward owner/repo from merge button to activate PR disambiguation guard

* fix(web): propagate owner/repo through BottomSheet and AttentionZone merge triggers

* test(web): update onMerge assertion to expect owner/repo arguments

* fix(core,web): resolve 5 multi-PR logical gaps — conflict detection, partial cache miss, isDraft aggregation, event context prs, AttentionZone labels

* fix(core,cli,plugins): add prs field to NotificationEventContext fixtures across all packages

* fix(web): add owner/repo guard to primary PR branch in webhook session match

* fix: handle unknown update versions and flat interactive config

* ci: pin GitHub Actions to SHAs

* fix: support new orchestrator from flat config

* ci: skip dependency review on unsupported forks

* revert: run dependency review on all PRs

* docs(design): add dashboard design-language exploration + mockups

Capture the design-language direction explored for the AO dashboard as
live HTML mockups plus rationale, under docs/design/.

- dashboard-language.md: the language — blue=orchestrator / orange=agents
  identity, rationed semantic color, Schibsted Grotesk + JetBrains Mono,
  unified status system, layout patterns, and how it diverges from DESIGN.md.
- mockups/kanban.html: canonical fleet board (frameless lifecycle columns).
- mockups/session.html: canonical session detail (framed xterm + pluggable
  inspector rail: Summary / Changes / Browser; review-comment "Address").
- mockups/{concepts,refined,*-icons}.html: exploration / icon comparisons.
- mockups/mascot.png: the blue conductor mascot.

Reference mockups only (CDN fonts + inline styles); production must use the
Tailwind tokens in globals.css per C-02/C-07.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(web): migrate dashboard to mission-control design language (supersedes DESIGN.md)

Replace the "Warm Terminal" system with the "Mission Control" language from
the merged design exploration (docs/design/dashboard-language.md + the kanban
& session mockups). One coherent system, dark control-room aesthetic, rationed
color (blue = orchestrator/you, orange = working agent, amber = needs-input,
red = failing/stuck, green = mergeable, else grayscale).

Tokens & fonts
- globals.css @theme/.dark: add literal palette (--bg #0a0b0d, --bg-side,
  --card #15171b = only bordered surface, --term #0c0d10, --line/--line-2,
  text ramp --t1..--t4, --blue/--orange/--amber/--red/--green) and re-point the
  existing --color-* semantic tokens to it (no token renames — alias + migrate).
  Flat surfaces, no warm gradients/glows. Dark theme preserved.
- Self-host Schibsted Grotesk (UI) + JetBrains Mono (machine) via
  next/font/local — no external font CDN. Geist Sans removed.

Status system (one spectrum, used everywhere)
- lib/status-spec.ts (getStatusSpec) + <StatusBadge> render the kanban card
  badge, sidebar dot, and session topbar pill from a single source; working dot
  breathes (CSS-only @keyframes breathe).

Fleet board (kanban.html)
- Frameless tinted columns Working -> Needs you -> In review -> Ready to merge
  with per-column semantic top-glow; the card is the only bordered surface
  (hairline ring, no status left-rail). Sidebar-shows-all-projects + SSE 5s
  unchanged.

Session detail (session.html)
- Framed xterm terminal: theme object tied to tokens (orange cursor, blue
  selection, token-mapped ANSI) — terminal content unstyled.
- New pluggable inspector rail <SessionInspector>: Summary (PR -> review
  comments -> Activity -> Overview) / Changes / Browser. Review comments keep
  the soft-blue Address action (askAgentToFix passing comment + file:line).
- Topbar: shared status pill, Kill, solid-blue Orchestrator primary; the PR
  popover is now mobile-only (desktop has the inspector). Blue mascot mark.

Tests: add status-spec / StatusBadge / SessionInspector / AppMark tests; update
column-label, theme, layout-metadata and PR-location assertions; mock
next/font/local in vitest. typecheck / test / lint / build all green.

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

* wip(web): compact card footer + cleanup before mockup-fidelity pass

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

* feat(web): mockup-faithful redesign pass — sidebar brand, resizable panels, cleaner chrome

Addresses review feedback to follow docs/design/mockups/{kanban,session}.html closely.

Sidebar (ProjectSidebar + mc-sidebar.css)
- Brand + blue mascot mark moved to the TOP of the sidebar (was in the topbar).
- Cool sidebar background (var(--color-bg-sidebar) #08090b) — removed the warm tint.
- Removed the yellow session-count chip (plain mono count) and the Review button.
- Per-project action icons now reveal on hover only.
- Consolidated the footer's four buttons into a single Settings gear + popover
  (show killed / show done / show session id / theme).
- Right edge is drag-resizable.

Board (Dashboard + AttentionZone + SessionCard + mc-board.css)
- Topbar brand removed; "Board" header + mockup subtitle; blue primary CTA.
- Frameless tinted columns with per-column glow; compact informational cards
  (status badge · id · hover terminal, 2-line title, branch w/ git icon, thin
  PR/CI footer). Removed inline quick-reply/alerts/CI-chips/merge+review buttons
  from cards (actions live on the session page); kept hover kill + terminal link.
- Split Done card into SessionCard.parts.tsx (≤400 lines).

Session detail (SessionDetail* + SessionInspector + TerminalControls + mc-session.css)
- Topbar: ‹ Kanban back button, title with branch to its RIGHT (git icon, no odd
  truncation), shared status pill, Kill, blue Orchestrator. Brand removed from the
  session shell header too.
- Terminal header: removed the "Connected" status text; matches the mockup
  (Terminal label · id · zoom · fullscreen). Terminal flush (no stray left margin).
- Terminal head and inspector tab bar are both 47px so their rules align.
- Inspector: PR → review comments → Activity timeline → Overview; left edge
  drag-resizable. Split header helpers into SessionDetailHeader.parts.tsx.

Shared infra
- useResizable hook (pointer drag → CSS var on :root, localStorage-persisted,
  no inline style); .resize-handle styles; per-screen mc-*.css loaded after
  globals.css in layout.tsx; sidebar/inspector widths read --ao-sidebar-w /
  --ao-inspector-w with sensible min/max + double-click reset.

typecheck / web tests (1005) / lint (0 errors) / build all green; resize verified.

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

* fix(web): sidebar/topbar polish from review feedback

- Notification bell icon 13→17px (was tiny inside its 34px button).
- Sidebar project rows: count shows at rest in the right slot; per-project
  action icons are now an absolutely-positioned hover-reveal group (out of
  flow) so the project name keeps full width and the row stays single-height
  (fixes the inflated rows / vertically-stacked icons).
- Session status dot nudged 1px to vertically center against the mono label.
- Orchestrator session page no longer renders the inspector rail (full-width
  terminal — nothing to inspect).

typecheck / web tests (1005) / lint (0 errors) green.

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

* fix(web): notification icon size + sidebar/topbar review fixes

- Notification bell on the session topbar was squeezed to a 6px sliver: the
  text-button padding rule applied to the icon-only button inside its fixed
  34px width. Give the bell its own 34px square / zero-padding rule → 17px icon.
- Remove the redundant sidebar toggle from the session topbar on desktop
  (mobile keeps the drawer hamburger); the sidebar owns collapse/expand.
- Collapsed sidebar now shows an expand button so it can be reopened.
- Trim sidebar padding + session-list indent so project/agent rows get more
  width.

typecheck / web tests (1005) / lint (0 errors) green; collapse↔expand + icon
size verified.

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

* fix(web): sidebar session names truncate with ellipsis; rename pencil no longer overlays

- The session label was an inline <span>, so text-overflow:ellipsis never
  applied — long names hard-clipped. Make it display:block so it ellipsizes.
- The rename pencil now absolutely positions at the row's right edge; at rest
  the label gets the full row width, and on hover the link gains right-padding
  so the text re-truncates and the pencil sits in the reserved space instead of
  covering the name.

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

* fix(web): project name reserves space for hover action icons (no overlap)

On hover the per-project action icons reveal over the right slot; the toggle now
gains right-padding so the project name re-truncates with ellipsis instead of
running underneath the icons — same pattern as the session rows.

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

* fix(web): remove kanban topbar toggle + redesign empty state to mission-control

- The board (kanban) topbar still rendered a desktop sidebar toggle; make it
  mobile-only (the sidebar owns collapse/expand), matching the session topbar.
- Empty state (Skeleton EmptyState): ghost columns now use the live board's
  labels (Working / Needs you / In review / Ready to merge), 4 columns, and the
  frameless tinted-trough look — removing the old dashed/warm-tinted columns and
  the stale 5-category set. Orchestrator glyph recolored to the blue accent via
  tokens (was hardcoded orange).

typecheck / web tests (1005) / lint (0 errors) green.

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

* fix(web): show project name in titlebar, not the raw project id hash

The board topbar special-cased projects named 'Agent Orchestrator' to display
the project id (e.g. agent-orchestrator_649ba24578) — unreadable gibberish.
Always show the human-readable project name instead.

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

* fix(core,web): detectPR branch guard, multi-PR prLabel aggregation, reset selectedPRIndex on session change

* fix(web): render terminal with xterm WebGL renderer (fixes broken box-drawing)

The dashboard terminal used xterm's default DOM renderer, which draws each
row as a separate DOM line and cannot tile box-drawing / block glyphs across
cells. Agent TUIs (Claude Code's bordered panels, the "N shell command"
expansion) rendered with broken/gappy frames and a grey blob — independent of
font or lineHeight (verified: even a complete font at lineHeight 1.0 breaks in
the DOM renderer).

Load xterm's WebGL renderer (@xterm/addon-webgl), which GPU-draws box/block
glyphs into each cell so frames connect and shaded regions stay solid. Loaded
rAF-deferred after open() with a DOM fallback on context-loss / when WebGL is
unavailable (headless, blocklisted GPU). This matches how superset renders
the same agent TUIs.

The WebGL addon only ships for the xterm 6.1.0-beta train, so bump
@xterm/xterm 6.0.0 -> 6.1.0-beta.256 and the addons to the matched beta set
(the same pair superset runs in production).

Also drops the earlier no-op fontWeight/fontWeightBold/letterSpacing change —
those values equalled xterm's defaults and had no visible effect.

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

* fix(web): enforce dark-mode contrast floor so agent blocks aren't grey blobs

Claude Code's expanded "Ran N shell command" block is painted on an ANSI
white background (ESC[47m). The terminal theme sets `white` ≈ `foreground`
(#c5ccd3), and dark mode used `minimumContrastRatio: 1` (no floor), so the
block's text was the same colour as its background — rendering as an
unreadable grey blob (only independently-coloured links/emoji showed through).

Raise the dark-mode floor to 4.5 (WCAG AA), matching the rationale already
used for light mode (7). xterm then auto-adjusts only the foregrounds that
fail the floor, leaving all other agent colours untouched. Verified in an
isolated harness with the real theme: white-on-white block goes from
invisible to legible.

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

* fix(web): load Unicode 11 addon so emoji/wide-char widths match tmux

Agent TUIs and tmux lay out tables treating emoji like / as 2 cells
wide (modern Unicode). xterm defaults to Unicode 6 width tables, where
those are 1 cell — so the grid shifts a column after every emoji, breaking
table borders and leaving stray text fragments (visible only in the web
terminal; a direct tmux attach via a modern terminal renders it fine).

Load @xterm/addon-unicode11 and set unicode.activeVersion = "11" so xterm's
width tables agree with tmux/the agent. Same beta train as the other addons.

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

* fix(web): rescale overlapping glyphs so out-of-font chars don't overlap text

JetBrains Mono is subset to Latin glyphs, so characters it lacks (arrows
→ ← ↔, CJK, emoji, powerline) are drawn from a fallback font whose advance
can exceed our monospace cell — the glyph then bleeds into the next cell and
overlaps the following character. Set rescaleOverlappingGlyphs so xterm shrinks
any glyph wider than its cell back to fit. Fixes the whole class of out-of-font
overflow, not just arrows.

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

* fix(web): ship full-coverage JetBrains Mono (restore arrows/box/blocks)

The self-hosted JetBrains Mono was subset to ~229 Latin glyphs, dropping
arrows (→ ← ↔), box-drawing, block elements and other symbols agents emit.
Those fell back to a system font at the wrong cell width — overlapping
(pre-rescale) or shrunk/blurry (post-rescale). Replace it with the full
JetBrains Mono variable face (OFL-1.1, wght 100–800, ~111 KB woff2, same
metrics) so those glyphs render in-font, full-size and crisp at the correct
monospace width. rescaleOverlappingGlyphs stays as a safety net for the few
glyphs even the full face lacks (CJK, emoji, powerline PUA).

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

---------

Co-authored-by: Laraib-1629 <laraibahmed73@gmail.com>
Co-authored-by: suraj-markup <sk9261712674@gmail.com>
Co-authored-by: itrytoohard <ayetrytoohard@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Priyanshu Choudhary <57816400+Priyanchew@users.noreply.github.com>
This commit is contained in:
prateek 2026-06-02 05:10:55 +05:30 committed by GitHub
parent e55012793c
commit c3eeecb686
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
116 changed files with 7430 additions and 2432 deletions

View File

@ -33,7 +33,7 @@ jobs:
name: Publish canary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
ref: main
fetch-depth: 0
@ -79,9 +79,9 @@ jobs:
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
if: steps.check.outputs.skip != 'true'
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
if: steps.check.outputs.skip != 'true'
with:
node-version: 20

View File

@ -20,9 +20,9 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 20
cache: pnpm
@ -33,9 +33,9 @@ jobs:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 20
cache: pnpm
@ -55,9 +55,9 @@ jobs:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 20
cache: pnpm
@ -80,9 +80,9 @@ jobs:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 20
cache: pnpm

View File

@ -19,12 +19,12 @@ jobs:
# Non-blocking: never prevent merging even if this job fails
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 20
cache: pnpm

View File

@ -78,7 +78,7 @@ jobs:
env:
DEPLOY_SHA: ${{ steps.resolve_deploy.outputs.deploy_sha }}
FETCH_REF: ${{ steps.resolve_deploy.outputs.fetch_ref }}
uses: appleboy/ssh-action@v1.2.2
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f
with:
host: ${{ secrets.VPS_HOST }}
username: aoagent

View File

@ -18,9 +18,9 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 20
cache: pnpm

View File

@ -19,10 +19,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
- name: Build test image
working-directory: tests/integration
@ -47,7 +47,7 @@ jobs:
- name: Upload test logs
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: onboarding-test-logs
path: |

View File

@ -39,12 +39,12 @@ jobs:
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 20
cache: pnpm
@ -65,7 +65,7 @@ jobs:
# `publish:` command. We deliberately omit `publish:` so the action
# never runs `changeset publish`. npm publishing is handled by a
# private cron that detects the GitHub release.
- uses: changesets/action@v1
- uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b
id: changesets
with:
version: pnpm changeset version

View File

@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0 # Full history to ensure base/head SHAs are available for PR scans
@ -80,10 +80,10 @@ jobs:
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Dependency Review
uses: actions/dependency-review-action@v4
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3
with:
fail-on-severity: moderate
@ -92,13 +92,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@7088e561eb65bb68695d245aa206f005ef30921d
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: 20
cache: pnpm

467
DESIGN.md
View File

@ -1,334 +1,181 @@
# Design System — Agent Orchestrator
## Product Context
- **What this is:** A web-based dashboard for managing fleets of parallel AI coding agents. Each agent gets its own git worktree, branch, and PR. The dashboard is the operator's single pane of glass.
- **Who it's for:** Developers running 10-30+ AI coding agents in parallel. From solo devs to engineering teams.
- **Space/industry:** AI agent orchestration. Competitors: Conductor.build, T3 Code, OpenAI Codex app. All are native Mac apps with cool blue-gray dark mode. Agent Orchestrator is the web-based alternative.
- **Project type:** Web app (Next.js 15, React 19, Tailwind v4). Kanban board with 6 attention-priority columns.
> **This document supersedes the previous "Warm Terminal" system.** AO's design
> language is **Mission Control**: a calm, high-signal control room for
> supervising a fleet of autonomous agents. The earlier warm-neutral direction
> (Geist Sans, amber/orange orchestrator CTA, brown-tinted surfaces) is retired.
> This file is the single source of truth — there is no second package-level
> `DESIGN.md`. Origin: the dashboard design-language exploration in
> [`docs/design/dashboard-language.md`](docs/design/dashboard-language.md) and
> its canonical mockups ([`kanban.html`](docs/design/mockups/kanban.html),
> [`session.html`](docs/design/mockups/session.html)).
## Aesthetic Direction
- **Direction:** Warm Terminal
- **Decoration level:** Intentional — subtle surface depth through warm gradients, inset highlights that catch light like brushed aluminum, ambient glow on active states. No decorative blobs, no gratuitous effects.
- **Mood:** High-end audio gear meets flight deck. Dense, scannable, utilitarian, with enough warmth that developers want to live in it for 10 hours. Every competitor is cold blue-gray. This is the warm one.
- **Reference sites:** Conductor.build (layout baseline), linear.app (density standard), t3.codes (terminal aesthetic)
## Product Context
- **What this is:** A web dashboard for supervising fleets of parallel AI coding agents. Each agent gets its own git worktree, branch, and PR. The dashboard is the operator's single pane of glass.
- **Who it's for:** Developers running 1030+ agents in parallel. It must stay calm and glanceable with 20+ agents running.
- **Project type:** Next.js 15 (App Router) + React 19 + Tailwind v4. A kanban fleet board (home) and a per-session detail view.
## Concept & Identity
**A calm, high-signal control room.** Linear-grade restraint, dense but humane.
State is glanceable, not noisy.
**The blue/orange split.** The mascot is the Claude Code character recolored
**blue** — the *conductor*. This drives a deliberate two-color semantic split:
- **Blue = the orchestrator (AO itself / "you").** Brand, the single solid-fill
primary CTA (the **Orchestrator** button), active selection, focus, links.
- **Orange = the agents being conducted.** The per-agent identity and the
**`working`** status — the one "an agent is alive right now" signal (a gently
breathing dot, the terminal cursor).
Blue does not *replace* orange; they mean different things. The board reads as a
blue conductor surrounded by orange agents.
## Color discipline
**Color = meaning. Most states get none.** The UI is grayscale by default;
color is rationed so it always signals something.
| Token | Hex | Use |
|-------|-----|-----|
| Blue | `#4d8dff` | orchestrator / you — primary action, selection, focus, links (the *only* solid-fill button) |
| Orange | `#f59f4c` | a working agent (status dot + terminal cursor) |
| Amber | `#e8c14a` | needs-your-input / attention (incl. unresolved review comments, changes requested) |
| Red | `#ef6b6b` | failing / stuck (CI failed, crashed, conflicts) |
| Green | `#74b98a` | mergeable / passed / resolved |
| Neutral grays | — | everything healthy & passive: in-review, idle, done, metadata |
Diff add/remove green & red are permitted in their literal context (the Changes view).
### Surfaces & lines (dark, cool neutral)
The product is **dark-only mission control**. The dark theme is authoritative.
| Token (literal) | Value | Maps to semantic token |
|-----------------|-------|------------------------|
| `--bg` | `#0a0b0d` | `--color-bg-base` (app base) |
| `--bg-side` | `#08090b` | `--color-bg-sidebar` |
| `--card` | `#15171b` | `--color-bg-surface` / `--color-bg-card`**the only bordered surface** |
| `--card-hover` | `#191b20` | `--color-bg-elevated` / `-elevated-hover` |
| `--col` | `#0e0f12` | `--color-column-bg` (kanban trough) |
| `--term` | `#0c0d10` | xterm background (terminal-themes.ts) |
| `--line` | `rgba(255,255,255,0.06)` | `--color-border-subtle` / `-default` |
| `--line-2` | `rgba(255,255,255,0.10)` | `--color-border-strong` |
| `--t1 … --t4` | `#f4f5f7` `#9ba1aa` `#646a73` `#444951` | `--color-text-primary/secondary/tertiary/muted` |
These literals live at the top of the `.dark` block in
`packages/web/src/app/globals.css`; the existing `--color-*` semantic tokens
**alias** them, so all consuming CSS keeps working. **Don't rename the semantic
tokens** — add/alias and migrate.
## Typography
- **Display/Hero:** JetBrains Mono, weight 500, letter-spacing -0.02em — monospace for headlines. In a dashboard where 40% of visible text is already monospace (agent output, branch names, commit hashes), leaning into mono for display creates a unified typographic voice instead of two competing voices.
- **Body:** Geist Sans, weight 400, letter-spacing -0.011em — purpose-built for dense interfaces at 13px. Better digit alignment than IBM Plex Sans, designed for exactly this density level.
- **UI/Labels:** Geist Sans, weight 600, letter-spacing 0.06em, uppercase, 10-11px — column headers, section labels, status indicators.
- **Data/Tables:** JetBrains Mono, weight 400, 11-13px, tabular-nums — agent IDs, branch names, timestamps, commit hashes, diff stats, PR numbers.
- **Code:** JetBrains Mono, weight 400 — terminal output, code blocks, inline code.
- **Loading:** Google Fonts via next/font/google. CSS variables: `--font-sans` (Geist), `--font-mono` (JetBrains Mono). Display strategy: swap.
- **Scale:**
- xs: 10px (timestamps, metadata)
- sm: 11px (secondary text, captions, labels)
- base: 13px (body text, card content)
- lg: 15px (section titles)
- xl: 17px (page titles)
- display: clamp(22px, 2.8vw, 32px) (hero headings)
## Color
- **Approach:** Restrained with signal accents. Color is a priority channel, not decoration. Warm tones throughout.
- **Accent (interactive):** #8b9cf7 — warm periwinkle. Links, focus rings, active states. Blue = clickable is muscle memory. This warm-leaning blue fits the palette without colliding with status colors.
- **Accent hover:** #a3b1fa
- **Accent tint:** rgba(139, 156, 247, 0.12)
- **Attention (warm):** #e2a336 — states requiring human input. Amber is universally "needs attention" without the panic of red.
Self-hosted via `next/font/local` (`packages/web/src/fonts/`). **No external font CDN.**
### Surfaces (Dark Mode)
| Token | Value | Usage | Rationale |
|-------|-------|-------|-----------|
| bg-base | #121110 | Page background | Brown-tinted black. Warmer than neutral #111 or blue-tinted #0a0d12. Sets the warm foundation. |
| bg-surface | #1a1918 | Card/column backgrounds | One stop lighter, same warm undertone. Surface hierarchy through subtle warmth, not just lightness. |
| bg-elevated | #222120 | Modals, popovers, hover states | Two stops up. Warm enough to feel distinct from surface without being muddy. |
| bg-elevated-hover | #2a2928 | Hover on elevated surfaces | Subtle lift on interaction. |
| bg-subtle | rgba(255, 240, 220, 0.04) | Subtle tints, pill backgrounds | Warm-tinted transparency. Reads as "highlighted" without introducing a new color. |
- **UI = Schibsted Grotesk** (`--font-sans`). The product voice. Used for all
chrome: titles, labels, buttons, body. A distinctive grotesk — not Inter/system.
- **Machine = JetBrains Mono** (`--font-mono`). Branches, IDs, PR numbers, costs,
timestamps, terminal — anything the machine emits.
- **Numerals:** `tabular-nums` wherever numbers appear (counts, costs, tokens).
- **Never render chrome in mono.** The sans/mono split is itself a design device:
product voice vs. machine voice.
### Surfaces (Light Mode)
| Token | Value | Usage | Rationale |
|-------|-------|-------|-----------|
| bg-base | #f5f3f0 | Page background | Warm parchment, not clinical white or cool gray. Matches the warm dark mode without being beige. |
| bg-surface | #ffffff | Card/column backgrounds | True white for cards creates contrast against the warm base. Cards "float" on warm paper. |
| bg-elevated | #ffffff | Modals, popovers | Same as surface. Light mode doesn't need as many elevation steps because shadows do the work. |
| bg-elevated-hover | #f7f5f2 | Hover states | Warm tint on hover, matching the base temperature. |
| bg-subtle | rgba(120, 100, 80, 0.05) | Subtle tints | Brown-tinted transparency for warm highlighting. |
(Geist Sans is removed. JetBrains Mono is no longer used for display headlines.)
**Light mode strategy:** Warm parchment base (#f5f3f0) with white cards. The same brown undertone that makes dark mode warm also makes light mode feel like quality paper, not sterile lab equipment. Accent darkened in light mode (#5c64b5) to maintain 5.3:1 contrast on white. Status colors shifted darker (green #16a34a, amber #b8860b, red #dc2626, cyan #0891b2) to maintain contrast on light backgrounds. Drop shadows replace inset highlights for surface hierarchy.
## Status as one system
### Text (Dark Mode)
| Token | Value | Usage |
|-------|-------|-------|
| text-primary | #f0ece8 | Headings, card titles, body. Cream, not pure white or blue-white. Warm and easy on the eyes at 3am. |
| text-secondary | #a8a29e | Descriptions, metadata. Stone-toned, not neutral gray. Readable in dense layouts. |
| text-tertiary | #78716c | Timestamps, placeholders, disabled states. Warm tertiary that recedes without disappearing. |
A single semantic spectrum maps the canonical lifecycle to a `{tone, label}`
pair and is used **everywhere** — kanban card badge, sidebar dot, session topbar
pill. It lives in [`lib/status-spec.ts`](packages/web/src/lib/status-spec.ts)
(`getStatusSpec`) and renders through
[`StatusBadge`](packages/web/src/components/StatusBadge.tsx).
### Text (Light Mode)
| Token | Value | Usage |
|-------|-------|-------|
| text-primary | #1c1917 | Headings, card titles, body. Warm near-black, not pure black. |
| text-secondary | #57534e | Descriptions, metadata. Stone-500. |
| text-tertiary | #736e6b | Timestamps, placeholders. Darkened from #a8a29e to pass WCAG AA (5.0:1 on white, 4.5:1 on base). |
| Tone | Color | Meaning |
|------|-------|---------|
| `working` | orange (breathing) | an agent is alive right now |
| `input` | amber | needs your input |
| `changes` | amber | changes requested |
| `fail` | red | CI failed / stuck / crashed / conflicts |
| `review` | neutral | in review / waiting on a reviewer |
| `ready` / `merged` | green | mergeable / landed |
| `neutral` | gray | idle / done / terminated |
### Borders (Dark Mode)
| Token | Value | Usage |
|-------|-------|-------|
| border-subtle | rgba(255, 240, 220, 0.06) | Dividers, section separators. Warm-tinted transparency. |
| border-default | rgba(255, 240, 220, 0.10) | Card edges, input borders. |
| border-strong | rgba(255, 240, 220, 0.18) | Hover states, focus indicators. |
Tone is refined from the (tested) attention-level bucket so a card's badge never
disagrees with the column it sits in.
### Status Colors
| Status | Dark Mode | Light Mode | CSS Token | Usage |
|--------|-----------|------------|-----------|-------|
| Working | #22c55e | #16a34a | `--status-working` | Agent actively coding. Green dot with pulse ring animation. |
| Ready | #8b9cf7 | #6b73c4 | `--status-ready` | Queued, awaiting start or CI pending. |
| Respond | #e2a336 | #b8860b | `--status-respond` | Needs human input. Amber = attention without panic. **NOT red** — "respond" is a normal workflow state. |
| Review | #06b6d4 | #0891b2 | `--status-review` | Code ready for review. Cyan = "look when ready." |
| Error | #ef4444 | #dc2626 | `--status-error` | CI failed, agent crashed. Red = broken. Distinct from Respond. |
| Done | #57534e | #d6d3d1 | `--status-done` | Completed. Fades to stone. Done items recede. |
## Layout patterns
**Critical:** `--status-respond` and `--status-error` are separate tokens with different semantic meanings. Respond = human decision needed (amber). Error = something broke (red). Never conflate them.
### Fleet board (home) — `kanban.html`
- **Lead with the fleet, not the terminal.** Answers "what are all my agents doing?" at a glance.
- **Frameless columns:** lifecycle columns **Working → Needs you → In review →
Ready to merge** are borderless tinted troughs with a faint *per-column*
semantic top-glow. The **card is the only bordered surface** — no box-in-box.
- **Compact cards:** status badge + id, task title (2-line clamp), branch, a thin
footer. Done/Terminated collapses at the bottom.
- The sidebar always shows **all projects'** sessions; the board filters
client-side. The SSE refresh interval is **5s** (unchanged — C-14).
- **Dark mode strategy:** Warm charcoal palette (brown-tinted, not neutral or blue-tinted gray). Reduce font weight by one step in dark mode (semibold becomes 500, bold becomes 600). Inset highlights on elevated surfaces: `inset 0 1px 0 rgba(255,255,255,0.03)`. Subtle radial gradients on body for ambient depth.
### Session detail — `session.html`
- **Framed terminal** as a real surface (header + viewport), flush to sidebar/topbar.
It is a **live xterm.js/PTY** — we do *not* style its content; we only set the
frame and the xterm.js `theme` object (background `--term`, orange cursor, blue
selection, a 16-color ANSI palette tied to the tokens — see `terminal-themes.ts`).
Claude Code's own input lives inside the terminal; there is no separate composer.
- **Pluggable inspector rail** (a registered-view slot):
[`SessionInspector`](packages/web/src/components/SessionInspector.tsx) with views
**Summary · Changes · Browser**; adding more (Logs, Cost…) is just another entry.
- *Summary* is ordered by supervision value: **Pull request → Review comments →
Activity → Overview** (the PR card bundles PR + review comments).
- *Review comments* surface a soft-blue **Address** action (`askAgentToFix`) that
hands the comment — with its `file:line` — to the agent session to fix.
- *Browser* is reserved for a web-preview / Playwright plugin.
- **Topbar:** ` Kanban` (back) · title + inline branch · **status pill** ·
notifications · **Kill** (trash) · **Orchestrator** (blue primary, org-chart icon).
## Spacing
- **Base unit:** 4px
- **Density:** Comfortable — dense enough for 30+ cards, spacious enough for 10-hour sessions
- **Scale:** 1(4) 2(8) 3(12) 4(16) 5(20) 6(24) 8(32) 10(40) 12(48) 16(64)
## Layout
- **Approach:** Grid-disciplined
- **Kanban grid:** 6 equal-width columns on desktop, 3 on tablet, stacked on mobile
- **Mobile column order:** Respond > Review > Pending > Working (urgency-first)
- **Max content width:** 1280px for settings/detail pages
- **Border radius:**
- 0px everywhere. No rounding on cards, buttons, inputs, modals, dropdowns. Hard edges are the identity. The only exception is status dots (circles by nature) and avatar images.
- full: 9999px (status dots, avatar circles only)
- **Card inset highlight:** `inset 0 1px 0 rgba(255,255,255,0.03)` in dark mode
- **Status accent:** 2px solid left border on session cards, colored by status
## Motion
- **Approach:** Intentional — every animation has a clear purpose and passes the frequency test
- **Easing:**
- enter/exit: `cubic-bezier(0.16, 1, 0.3, 1)` (spring-like deceleration, feels responsive)
- move/morph: `cubic-bezier(0.77, 0, 0.175, 1)` (natural acceleration/deceleration)
- hover/color: `ease-out`
- constant (spinner, marquee): `linear`
- **Duration:**
- micro: 100-160ms (button press, hover state)
- short: 150-200ms (tooltips, popovers, card entrance)
- medium: 200-300ms (modals, drawers, card expand)
- long: 2s (status dot pulse, continuous indicators)
- **Card entrance:** `translateY(8px)` + opacity, 0.2s with 40ms stagger between siblings
- **Status pulse:** GPU-composited pseudo-element on Working dots. `transform: scale(0.8→1.3)` + `opacity: 0.5→0`, 2s ease-in-out infinite. Not box-shadow (triggers paint).
- **Button press:** `transform: scale(0.97)` on `:active`, 160ms ease-out
- **Rules:**
- Never animate keyboard-initiated actions (command palette toggle, shortcuts)
- One animation per element, one purpose per animation
- CSS transitions for interruptible UI, keyframes for continuous indicators
- All animations must respect `prefers-reduced-motion: reduce`
- Use `contain: layout style paint` on session cards for performance with 30+ cards
## Accessibility
- **Touch targets:** Minimum 44x44px on all interactive elements (buttons, links, toggles). Icon buttons that render smaller visually must have padding to meet 44px minimum hit area.
- **Contrast ratios (WCAG AA):**
- Body text (13px): 4.5:1 minimum against surface backgrounds
- Large text (18px+ or 14px bold): 3:1 minimum
- UI components (borders, icons): 3:1 minimum against adjacent colors
- Dark: text-primary #f0ece8 on bg-surface #1a1918: 14.9:1 ✓
- Dark: text-secondary #a8a29e on bg-surface #1a1918: 7.0:1 ✓
- Dark: text-tertiary #78716c on bg-surface #1a1918: 3.7:1 ✓ (labels only, not body text)
- Dark: accent #8b9cf7 on bg-surface #1a1918: 6.9:1 ✓
- Light: text-primary #1c1917 on bg-surface #ffffff: 17.5:1 ✓
- Light: text-secondary #57534e on bg-surface #ffffff: 7.6:1 ✓
- Light: text-tertiary #736e6b on bg-surface #ffffff: 5.0:1 ✓
- Light: accent #5c64b5 on bg-surface #ffffff: 5.3:1 ✓
- **Focus indicators:** `outline: 2px solid var(--accent); outline-offset: 2px` on `:focus-visible`. Never `outline: none` without a visible replacement.
- **Reduced motion:** `@media (prefers-reduced-motion: reduce)` disables all animations and transitions globally. Non-negotiable.
- **Color independence:** Never encode meaning with color alone. Always pair colored dots with text labels. Status pills include both dot and text.
- **Keyboard navigation:** All interactive elements reachable via Tab. Logical tab order. Escape closes modals/popovers. Arrow keys navigate within lists.
- **Screen reader:** ARIA labels on all icon-only buttons. `role="heading"` with `aria-level` on non-heading elements styled as headings. Status changes announced via `aria-live` regions.
## Iconography & motion
- **Line icons only** (Lucide-style, ~1.6px stroke, `currentColor`, inline SVG). **No emoji.**
- **Motion is minimal & purposeful:** a slow CSS-only "breathe" pulse on the
working dot / terminal cursor (`@keyframes breathe`, 2.4s). No animation
libraries (C-07). All motion respects `prefers-reduced-motion: reduce`.
## Web Implementation Rules
- **Single source of truth:** This file is the authoritative design spec for the repo, including `packages/web/`. Do not create a second package-level `DESIGN.md`.
- **Tokens over raw values:** In React/Tailwind code, use CSS variables from `packages/web/src/app/globals.css` instead of hardcoded hex, rgba, or `dark:` overrides.
- **No inline styles:** Avoid `style={{ ... }}` for theme values. Use Tailwind utilities with `var(--token)` or add a named class in `globals.css`.
- **No external UI kits:** Do not introduce Radix, shadcn, Headless UI, or similar component libraries for core UI primitives.
- **Tailwind vs CSS classes:** Use Tailwind for one-off layout and spacing. Add a class in `globals.css` when a pattern is theme-sensitive, uses pseudo-elements, gradients, or repeats 3+ times.
- **Theme handling:** `:root` holds light tokens and `.dark` holds dark overrides. Use tokenized classes like `bg-[var(--color-bg-surface)]`, not `bg-white dark:bg-[#1a1918]`.
- **Tokens over raw values.** Use the `--color-*` semantic tokens (or the literal
`--bg/--card/--t1…` palette) from `globals.css`. No hardcoded hex/rgba in components.
- **No inline `style=`** for theme values (C-02). Tailwind utilities with
`var(--token)`, or a named class in `globals.css`.
- **No external UI kits** (Radix, shadcn, Headless UI, …) (C-01).
- **Tailwind vs CSS classes:** Tailwind for one-off layout/spacing; add a class in
`globals.css` when a pattern is theme-sensitive, uses pseudo-elements/gradients,
or repeats 3+ times.
- **App Router only** (C-06). Component files ≤ 400 lines (C-04). Test files for
new/changed components (C-12).
- **Dark theme is always preserved** (C-05). Light-mode tokens still exist for the
theme toggle but mission control is designed and tuned for dark.
## Token Mapping
These are the concrete token names used in `packages/web/src/app/globals.css`. New UI code should reference these names directly.
## Accessibility
- **Focus indicators:** `outline: 2px solid var(--color-accent); outline-offset: 2px` on `:focus-visible`. Never `outline: none` without a visible replacement.
- **Reduced motion:** `@media (prefers-reduced-motion: reduce)` disables animations/transitions. Non-negotiable.
- **Color independence:** never encode meaning with color alone. Status badges always pair a colored dot with a text label.
- **Contrast:** body text ≥ 4.5:1; UI/borders/icons ≥ 3:1. The text ramp `--t1…--t3` is for primary→labels on the `--bg`/`--card` surfaces; `--t4` is for faint/disabled only.
- **Keyboard nav:** all interactive elements reachable via Tab; Escape closes popovers; logical order.
- **ARIA labels** on all icon-only buttons.
### Core Surface Tokens
| CSS Token | Meaning |
|-----------|---------|
| `--color-bg-base` | Page background |
| `--color-bg-surface` | Standard card/panel background |
| `--color-bg-elevated` | Elevated surfaces, popovers, modals |
| `--color-bg-elevated-hover` | Hover state for elevated surfaces |
| `--color-bg-subtle` | Subtle fill for chips, hovers, muted emphasis |
| `--color-bg-sidebar` | Sidebar-specific background |
### Core Text and Border Tokens
| CSS Token | Meaning |
|-----------|---------|
| `--color-text-primary` | Primary headings/body copy |
| `--color-text-secondary` | Supporting text |
| `--color-text-tertiary` | Captions/placeholders |
| `--color-text-muted` | Low-emphasis meta text |
| `--color-text-inverse` | Text on accent or dark fills |
| `--color-border-subtle` | Hairline dividers |
| `--color-border-default` | Standard borders |
| `--color-border-strong` | Emphasized borders/focus-adjacent borders |
### Accent and Utility Tokens
| CSS Token | Meaning |
|-----------|---------|
| `--color-accent` | Primary interactive accent |
| `--color-accent-hover` | Hover state for accent surfaces |
| `--color-accent-subtle` | Accent-tinted background |
| `--color-accent-blue` | Semantic blue alias |
| `--color-accent-green` | Semantic green alias |
| `--color-accent-yellow` | Semantic amber alias |
| `--color-accent-orange` | Semantic orange alias |
| `--color-accent-red` | Semantic red alias |
| `--color-tint-blue` | Blue pill/badge background |
| `--color-tint-green` | Green pill/badge background |
| `--color-tint-yellow` | Yellow pill/badge background |
| `--color-tint-orange` | Orange pill/badge background |
| `--color-tint-red` | Red pill/badge background |
| `--color-tint-neutral` | Neutral pill/badge background |
### Status and Alert Tokens
| CSS Token | Meaning |
|-----------|---------|
| `--color-status-working` | Agent actively working |
| `--color-status-ready` | Ready/queued state |
| `--color-status-respond` | Human response needed |
| `--color-status-review` | Review-needed state |
| `--color-status-pending` | Pending/queued emphasis |
| `--color-status-merge` | Merge-ready/merged emphasis |
| `--color-status-idle` | Idle/inactive state |
| `--color-status-done` | Completed/receding state |
| `--color-status-error` | Error/broken state |
| `--color-ci-pass` | CI passing |
| `--color-ci-fail` | CI failing |
| `--color-alert-ci` / `--color-alert-ci-bg` | CI failure callout row |
| `--color-alert-review` / `--color-alert-review-bg` | Review-requested callout row |
| `--color-alert-changes` / `--color-alert-changes-bg` | Changes-requested callout row |
| `--color-alert-conflict` / `--color-alert-conflict-bg` | Merge-conflict callout row |
| `--color-alert-comment` / `--color-alert-comment-bg` | New-comment callout row |
## Component Anatomy
### Session Card
```
┌─ 2px left border (status color) ─────────────────────┐
│ ┌─ Card (bg-surface, 1px border-default, 2px radius) │
│ │ Title (text-primary, 12px, weight 500) │
│ │ Branch · PR # (mono, text-tertiary, 10px) │
│ │ ┌─ Status pill ────────────────────┐ │
│ │ │ ● dot (6px, status color) Label │ │
│ │ └──────────────────────────────────┘ │
│ │ inset 0 1px 0 rgba(255,255,255,0.03) (dark only) │
│ └─────────────────────────────────────────────────────│
└───────────────────────────────────────────────────────┘
```
- **Padding:** 10px 12px
- **Spacing:** 4px between title and meta, 6px between meta and status
- **Hover:** bg-elevated-hover, border-color transition 0.12s
- **Active:** scale(0.99), 80ms
- **Containment:** `contain: layout style paint` for 30+ card performance
### Button States
| State | Primary | Secondary | Ghost | Danger |
|-------|---------|-----------|-------|--------|
| Rest | bg: accent, text: #121110 | bg: elevated, border: border-default | bg: transparent | bg: transparent, border: red/30% |
| Hover | bg: accent-hover | bg: elevated-hover, border: border-strong | bg: bg-subtle | bg: red/8%, border: red |
| Active | scale(0.97) | scale(0.97) | scale(0.97) | scale(0.97) |
| Focus | outline: 2px accent | outline: 2px accent | outline: 2px accent | outline: 2px accent |
| Disabled | opacity: 0.5, cursor: not-allowed | opacity: 0.5 | opacity: 0.5 | opacity: 0.5 |
- **Padding:** 8px 16px
- **Font:** Geist Sans, 13px, weight 500
- **Border-radius:** 0
- **Min touch target:** 44px height (add padding if needed)
### Input Fields
| State | Appearance |
|-------|------------|
| Rest | bg: bg-base, border: border-default, text: text-primary |
| Placeholder | color: text-tertiary |
| Focus | border-color: accent, no outline (border IS the indicator) |
| Error | border-color: status-error, error message below in status-error color |
| Disabled | opacity: 0.5, cursor: not-allowed, bg: bg-subtle |
- **Padding:** 8px 12px
- **Font:** Geist Sans, 13px
- **Border-radius:** 0
### Status Pill
- **Layout:** inline-flex, center-aligned, gap 6px
- **Dot:** 6px circle, filled with status color
- **Text:** 11px, weight 600, text-secondary
- **Background:** bg-subtle
- **Padding:** 4px 10px
- **Border-radius:** 0
### Alert / Banner
- **Layout:** flex, padding 12px 16px
- **Left border:** 2px solid, colored by severity
- **Background:** status color at 6% opacity
- **Text:** status color, 13px
- **Border-radius:** 0
- **Variants:** success (green), warning (amber), error (red), info (cyan)
## Web Patterns
- **Mono data:** IDs, hashes, timestamps, branch names, and PR numbers should use `font-mono` with `10-11px` sizing and slightly wider tracking.
- **Status text:** Session/card status labels should stay mono and low-emphasis unless the status itself is the primary signal.
- **Alert rows:** Inline alert/callout rows inside cards should use a 2px left border plus paired foreground/background alert tokens.
- **Dividers:** Use `border-[var(--color-border-subtle)]` or `border-[var(--color-border-default)]` instead of ad hoc neutral grays.
- **Existing reusable components:** Prefer current primitives/components like `ActivityDot`, `CIBadge`, `PRStatus`, `Toast`, and shared layout patterns already in `packages/web/src/components/`.
- **Sharp edges remain the rule:** Do not reintroduce rounded cards/buttons as a package-level convention. `rounded-full` is reserved for dots, pills, and avatars.
## Performance Guidelines
- Use `contain: layout style paint` and `content-visibility: auto` on session cards
- Animate only `transform` and `opacity` (GPU-composited). Never animate `padding`, `margin`, `height`, `width`, `border`, or `box-shadow`.
- Status dot pulse must use pseudo-element with `will-change: transform, opacity`, not box-shadow rings
- Backdrop blur on nav capped at 12px (diminishing returns above 12)
- Pause all non-essential animations when tab is hidden
## Anti-Patterns (Never Do)
- Purple/violet gradients as default accent
- 3-column feature grid with icons in colored circles
- Centered everything with uniform spacing
- Uniform bubbly border-radius (8-12px) on all elements
- Gradient buttons as primary CTA pattern
- `transition: all` — always specify exact properties
- `scale(0)` entry animations — start from `scale(0.95)` with `opacity: 0`
- `ease-in` on UI elements — use `ease-out` for responsiveness
- Animations over 300ms on frequently-triggered UI elements
- Neutral gray surfaces (#111, #222) — always use warm-tinted variants
- Blue-white text (#eef3ff) — use cream (#f0ece8) to maintain warmth
- `outline: none` without a visible focus replacement
## Constraints
- C-01: No new UI component libraries
- C-02: No inline styles in new/modified code
- C-04: Component files max 400 lines
- C-05: Dark theme preserved
- C-06: Next.js App Router only
- C-07: No animation libraries (CSS-only motion)
- C-12: Test files for new/changed components
- C-14: SSE 5s interval unchanged
## Decisions Log
| Date | Decision | Rationale |
|------|----------|-----------|
| 2026-03-28 | Initial design system created | Created by /design-consultation with competitive research (Conductor.build, T3 Code, OpenAI Codex, Emdash) + 4 design voices |
| 2026-03-28 | Geist Sans + JetBrains Mono (2 fonts only) | Emil review: 4 fonts creates cognitive gear-shifts on scan-heavy dashboards |
| 2026-03-28 | 2px base border-radius (v1) | Full 0px risks looking unstyled. 2px reads as intentionally sharp while feeling designed. |
| 2026-04-05 | 0px border-radius everywhere | Hard edges are the identity. With warm surfaces and inset highlights providing depth, rounding adds nothing. Zero radius is the most honest expression of Industrial/Warm Terminal. |
| 2026-03-28 | Keep dot pulse, remove border heartbeat | Emil review: 4s border animation on 15+ cards is "decorative anxiety" with high perf cost. |
| 2026-04-05 | Fresh design system: Warm Terminal | Every competitor converges on cool blue-gray. Warm charcoal with cream text and warm periwinkle accent creates instant visual distinction. |
| 2026-04-05 | JetBrains Mono for display + data | Mono headlines in a mono-heavy dashboard create typographic cohesion instead of two competing voices. Free, open source, already in the codebase. |
| 2026-04-05 | Warm periwinkle #8b9cf7 accent (not gold) | Gold collides semantically with amber attention state. Blue = clickable is muscle memory. Warm periwinkle fits the palette without signal confusion. |
| 2026-04-05 | Brown-tinted surfaces, not neutral or blue-tinted | #121110 / #1a1918 / #222120 — warm undertone sets AO apart from every Linear clone. Light mode uses warm parchment #f5f3f0. |
| 2026-04-05 | Added accessibility section | Missing from v1. Touch targets 44px min, WCAG AA contrast, focus-visible, prefers-reduced-motion. |
| 2026-04-05 | Added component anatomy section | Missing from v1. Button states, input states, card structure, status pill, alert anatomy. |
| 2026-04-05 | Added light mode rationale | v1 listed values without explaining why. Warm parchment base, white card float, desaturated accent. |
| 2026-04-07 | `--status-respond` is amber, never red | Critique found Respond column using `--status-error` (red). Respond = human decision needed, not error. Separate token. See status colors table. |
| 2026-04-07 | Two-stage delete confirmation pattern | P0 safety: trash button first click enters amber "kill?" state for 2s; second click confirms. No modal. In-place via CSS `::after` + JS class toggle. Prevents accidental agent termination. |
| 2026-04-07 | Working card titles at full weight | P2: Working state is the primary operational state. Never dim active card titles. Dimming is reserved for Done/archived cards only. |
| 2026-04-07 | No current-project group label in sidebar sessions | P2: Current project label in the sessions list is redundant — the project switcher chip 12px above already names the project. Only other projects need group labels. |
| 2026-04-07 | Remove column shadows | P3: `18px/42px` column box-shadow created competing depth layers with card shadows. Border + background contrast does separation. No column-level shadow needed. |
| 2026-04-07 | Topbar shows page name only, not project name | Minor: Topbar "vinesight-rn / kanban" duplicated project name visible in sidebar. Topbar now shows "Kanban" + freshness. |
| 2026-04-07 | Diff size badges use `<abbr>` with tooltip | Minor: S/M/L diff badges were opaque. `<abbr title="Small (<100 lines)">` gives meaning on hover without adding visual noise. |
| 2026-04-27 | Sidebar always shows ALL projects' sessions | P0: Sidebar is not filtered by active project — `useSessionEvents` called without project filter, kanban filters client-side via `projectSessions` memo. |
| 2026-05-27 | **Mission Control supersedes Warm Terminal** | A single source of truth. The product is a calm control room for a fleet of agents; cool restraint + rationed color reads better at 20+ agents than warm decoration. |
| 2026-05-27 | Blue = orchestrator/you, orange = working agent | The mascot is the blue conductor; orange is the Claude Code lineage. Two colors, two meanings — the product metaphor, visualized. |
| 2026-05-27 | Schibsted Grotesk (UI) + JetBrains Mono (machine), self-hosted | A distinctive grotesk for the product voice; mono reserved for machine data. Self-hosted via `next/font/local` — no external font CDN. |
| 2026-05-27 | One status system (`getStatusSpec` + `StatusBadge`) | Kanban badge, sidebar dot, and topbar pill all render from one spectrum so status never disagrees with itself. |
| 2026-05-27 | The card is the only bordered surface | Frameless tinted columns with per-column glow; cards are flat `--card` with a hairline ring. No box-in-box nesting. |

View File

@ -0,0 +1,129 @@
# Dashboard Design Language — exploration (ao-2-1)
A design-language exploration for the AO dashboard, captured as live HTML mockups
plus the rationale behind them. This is an **iteration on / proposal alongside**
the existing [`DESIGN.md`](../../DESIGN.md) — see [Relationship to DESIGN.md](#relationship-to-designmd)
for where it diverges and what needs reconciling before any production work.
> These are **reference mockups**, not production code. They use a Google-Fonts
> CDN and inline `<style>`/`style=` for speed of iteration. The production
> implementation must use the AO Tailwind v4 tokens in `globals.css`, no inline
> styles (C-02), no external font CDN, and CSS-only motion (C-07).
## Mockups
| File | What it is |
|------|-----------|
| [`mockups/kanban.html`](mockups/kanban.html) | **Canonical** — the fleet board (home view): lifecycle columns of agent-session cards. |
| [`mockups/session.html`](mockups/session.html) | **Canonical** — the agent session detail page: framed terminal + pluggable inspector rail. |
| [`mockups/mascot.png`](mockups/mascot.png) | The mascot — Claude Code's character recolored blue (the conductor). |
| `mockups/concepts.html` | Exploration — three early directions (A refined / B terminal-craft / C bold console). |
| `mockups/refined.html` | Exploration — the first restrained single-screen pass. |
| `mockups/orchestrator-icons.html`, `orgchart-icons.html`, `address-icons.html` | Exploration — icon candidate comparisons. |
## Concept
**A calm, high-signal control room for supervising a fleet of autonomous agents.**
Linear-grade restraint, dense but humane. The product must stay calm with 20+
agents running: state is glanceable, not noisy.
## Identity — the blue/orange split
The mascot is the Claude Code character recolored **blue**, holding a wand — it's
the *conductor*. This drives a deliberate two-color semantic split:
- **Blue = the orchestrator (AO itself / "you").** Brand, the single primary CTA
(the **Orchestrator** button), active selection, focus, links.
- **Orange = the agents being conducted (the Claude Code lineage).** The per-agent
identity and the **`working`** status — the one "an agent is alive right now"
signal (a gently breathing dot, the terminal cursor).
Blue does not *replace* orange; they mean different things. The fleet board reads
as a blue conductor surrounded by orange agents — the product's metaphor, visualized.
## Color discipline
**Color = meaning. Most states get none.** The UI is grayscale by default; color
is rationed so it always signals something:
| Token | Use |
|-------|-----|
| Blue `#4d8dff` | orchestrator / you — primary action, selection, focus (the *only* solid-fill button) |
| Orange `#f59f4c` | a working agent (status + cursor) |
| Amber `#e8c14a` | needs-your-input / attention (incl. unresolved review comments) |
| Red `#ef6b6b` | failing (CI failed, stuck) |
| Green `#74b98a` | mergeable / passed / resolved |
| Neutral grays | everything healthy & passive: in-review, idle, done, metadata |
Diff add/remove green & red are allowed in their literal context (the Changes view).
### Surfaces & lines (dark, cool neutral)
```
--bg #0a0b0d (app base) --card #15171b (the only bordered surface)
--bg-side #08090b (sidebar) --term #0c0d10 (terminal / xterm background)
--line rgba(255,255,255,0.06) --line-2 rgba(255,255,255,0.10)
--t1 #f4f5f7 --t2 #9ba1aa --t3 #646a73 --t4 #444951 (text ramp: primary→faint)
```
## Typography
- **UI:** *Schibsted Grotesk* (distinctive grotesk, not Inter/system). The product voice.
- **Machine:** *JetBrains Mono* — branches, IDs, PR numbers, costs, timestamps, terminal.
- **Numerals:** `tabular-nums` wherever numbers appear (counts, costs, token totals).
The split between UI sans (product voice) and mono (machine voice) is itself a
design device — never render chrome in mono.
## Status as one system
A single semantic status spectrum maps to the canonical lifecycle and is used
everywhere (kanban dot, card badge, session topbar pill): `working` (orange,
breathing) · `needs input` (amber) · `CI failed` (red) · `in review` (neutral) ·
`changes requested` (amber) · `mergeable` (green) · `idle` / `done` (neutral).
## Layout patterns
### Fleet board (`kanban.html`)
- **Lead with the fleet, not the terminal.** The home view answers "what are all my
agents doing?" at a glance.
- **Frameless columns:** lifecycle columns (Working → Needs you → In review → Ready
to merge) are borderless tinted troughs with a faint *per-column* semantic
top-glow. The **card is the only bordered surface** — no box-in-box nesting.
- Compact cards: status + id, task title (2-line clamp), branch, one thin footer
(PR / CI / cost). Done/Terminated collapses at the bottom.
### Session detail (`session.html`)
- **Framed terminal** as a real surface (header + viewport), flush to sidebar/topbar.
The terminal is a **live xterm.js/PTY** — we do *not* style its content; we only
set the frame and the xterm.js `theme` object (background, foreground, cursor,
and a harmonized 16-color ANSI palette tied to these tokens). No separate message
composer; Claude Code's own input lives inside the terminal.
- **Pluggable inspector rail** (a view slot): **Summary · Changes · Browser**, each a
registered view; adding more (Logs, Cost, …) is just another entry.
- *Summary* is ordered by supervision value: **Pull request → Review comments →
Activity → Overview** (metadata last).
- *Review comments* surface an **Address** action (soft blue, not a loud CTA) that
hands the comment — with its `file:line` — to the agent session to fix.
- *Browser* renders what the agent is viewing (web-preview / Playwright plugin).
- **Topbar:** ` Kanban` (back to board) · title + inline branch · status · then
notifications · **Kill** (trash icon) · **Orchestrator** (blue primary, org-chart icon).
## Iconography & motion
- **Line icons only** (Lucide-style, ~1.6px stroke, `currentColor`). **No emoji.**
- **Motion is minimal & purposeful:** a slow "breathe" pulse on the working dot/cursor.
CSS-only.
## Relationship to DESIGN.md
This exploration diverges from the current [`DESIGN.md`](../../DESIGN.md) and these
points need a deliberate decision before production:
| Topic | DESIGN.md (current) | This exploration |
|-------|--------------------|------------------|
| Direction | "Warm Terminal" (warm neutrals) | Cool, restrained "mission control" |
| Accent meaning | amber/orange orchestrator CTA | **blue = orchestrator, orange = agents** |
| UI font | Geist Sans | Schibsted Grotesk |
| Display | JetBrains Mono headlines | UI sans headlines; mono reserved for machine data |
Recommendation: reconcile into a single source of truth (update `DESIGN.md` or
formally supersede it) before implementing — don't ship two conflicting systems.

View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Address button — icon options</title>
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<style>
:root{--bg:#0a0b0d;--card:#15171b;--t1:#f4f5f7;--t2:#9ba1aa;--t3:#646a73;--t4:#444951;
--line:rgba(255,255,255,0.06);--line-2:rgba(255,255,255,0.10);--blue-soft:#7eaaff;}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--t1);font-family:"Schibsted Grotesk",sans-serif;-webkit-font-smoothing:antialiased;padding:40px 46px 80px}
h1{font-size:20px;font-weight:700;letter-spacing:-.02em}
.lede{font-size:13px;color:var(--t3);margin:7px 0 28px;max-width:620px;line-height:1.5}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:13px}
.cell{background:var(--card);border-radius:11px;box-shadow:inset 0 0 0 1px var(--line);padding:22px 16px 15px;display:flex;flex-direction:column;align-items:center;gap:15px}
.cell.cur{box-shadow:inset 0 0 0 1px rgba(77,141,255,.4)}
.addr{padding:6px 12px;border-radius:6px;background:rgba(77,141,255,.1);color:var(--blue-soft);font-size:12px;font-weight:600;display:inline-flex;align-items:center;gap:6px;cursor:pointer}
.addr:hover{background:rgba(77,141,255,.18)}
.cap{text-align:center}.cap .nm{font-size:12px;font-weight:600}.cap .ds{font-size:11px;color:var(--t3);margin-top:3px;line-height:1.4}
.badge{font-family:monospace;font-size:9px;color:var(--blue-soft);background:rgba(77,141,255,.14);padding:1px 6px;border-radius:4px;margin-top:7px;display:inline-block}
</style></head><body>
<h1>“Address” button — icon candidates</h1>
<p class="lede">The action hands a review comment to the agent to fix. Icon should read as “send to agent / auto-resolve,” shown on the real tinted button.</p>
<div class="grid" id="g"></div>
<script>
const S=p=>`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
const O=[
{nm:"Wand + sparkle",cur:true,ds:"current — agent magic-fixes it",
ic:`<path d="m4 20 7-7"/><path d="M14.5 4.5 16 2l2 1.5L16.5 6z"/><path d="m17 9 .5 1.6 1.6.5-1.6.5L17 13l-.5-1.6L14.9 11l1.6-.5z"/>`},
{nm:"Paper plane",ds:"send the comment to the agent",
ic:`<path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4z"/>`},
{nm:"Return / route",ds:"route into the session (chat-send glyph)",
ic:`<path d="M9 10 4 15l5 5"/><path d="M4 15h11a5 5 0 0 0 5-5V4"/>`},
{nm:"Sparkles",ds:"auto-fix, no stick",
ic:`<path d="M11 3 12.7 7.3 17 9 12.7 10.7 11 15 9.3 10.7 5 9 9.3 7.3z"/><path d="M18 14l.7 1.9 1.9.7-1.9.7L18 19.2l-.7-1.9-1.9-.7 1.9-.7z"/>`},
{nm:"Wrench",ds:"fix it",
ic:`<path d="M14.5 5.5a3.5 3.5 0 0 1-4.6 4.6L4 16v4h4l5.9-5.9a3.5 3.5 0 0 0 4.6-4.6l-2.3 2.3-2-2z"/>`},
{nm:"Forward arrow",ds:"hand off to the agent",
ic:`<path d="M13 17l5-5-5-5"/><path d="M6 17l5-5-5-5"/>`},
{nm:"Check + arrow",ds:"send → it gets resolved",
ic:`<path d="M20 6 9 17l-5-5"/><path d="M14 18h7v-7" opacity=".0"/><path d="M16 4h4v4"/>`},
{nm:"Bolt",ds:"quick auto-resolve",
ic:`<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>`},
{nm:"Bot",ds:"literal — agent takes it",
ic:`<rect x="4" y="8" width="16" height="11" rx="2.5"/><path d="M12 4.5V8"/><circle cx="12" cy="3.5" r="1.2"/><circle cx="9" cy="13.5" r="1"/><circle cx="15" cy="13.5" r="1"/>`},
];
document.getElementById('g').innerHTML=O.map(o=>`
<div class="cell ${o.cur?'cur':''}">
<button class="addr">${S(o.ic)}Address</button>
<div class="cap"><div class="nm">${o.nm}</div><div class="ds">${o.ds}</div>${o.cur?'<span class="badge">CURRENT</span>':''}</div>
</div>`).join('');
</script></body></html>

View File

@ -0,0 +1,356 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AO — Design Direction Concepts</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700&family=Hanken+Grotesk:wght@400;500;600;700;800&family=Archivo:wght@500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600;700&family=Spline+Sans+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<style>
/* ============================================================
SHARED RESET + SCAFFOLD
============================================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: #0a0a0c;
color: #fff;
font-family: "Hanken Grotesk", sans-serif;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
button { font: inherit; cursor: pointer; border: none; background: none; color: inherit; }
/* Direction switcher */
.switcher {
position: fixed; top: 0; left: 0; right: 0; height: 52px; z-index: 100;
display: flex; align-items: center; gap: 4px;
padding: 0 16px;
background: #050506;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.switcher .brand { font-weight: 700; font-size: 13px; letter-spacing: -0.02em; margin-right: 16px; opacity: 0.7; }
.switcher .brand b { color: #4d8dff; }
.switcher button.tab {
padding: 7px 14px; border-radius: 8px; font-size: 12.5px; font-weight: 600;
color: rgba(255,255,255,0.5); transition: all 0.15s;
}
.switcher button.tab:hover { color: rgba(255,255,255,0.85); background: rgba(255,255,255,0.05); }
.switcher button.tab.active { color: #fff; background: rgba(77,141,255,0.16); box-shadow: inset 0 0 0 1px rgba(77,141,255,0.4); }
.switcher .note { margin-left: auto; font-size: 11px; color: rgba(255,255,255,0.35); font-family: "JetBrains Mono", monospace; }
.stage { position: fixed; inset: 52px 0 0 0; }
.concept { display: none; height: 100%; }
.concept.active { display: block; }
/* ============================================================
SHARED LAYOUT GRID (each theme overrides tokens)
============================================================ */
.app {
height: 100%;
display: grid;
grid-template-columns: 248px 1fr;
grid-template-rows: 56px 1fr;
grid-template-areas: "side top" "side main";
background: var(--bg);
color: var(--text);
font-family: var(--ui);
}
.sidebar { grid-area: side; background: var(--bg-side); border-right: 1px solid var(--border); overflow-y: auto; padding: 14px 10px; }
.topbar { grid-area: top; background: var(--bg-top); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 14px; padding: 0 18px; }
.main { grid-area: main; overflow-y: auto; padding: 22px 26px 60px; }
/* sidebar */
.side-head { display: flex; align-items: center; justify-content: space-between; padding: 2px 8px 14px; }
.side-head .logo { display: flex; align-items: center; gap: 9px; }
.logo-mark { width: 22px; height: 22px; border-radius: 6px; background: var(--accent); display: grid; place-items: center; font-size: 12px; box-shadow: 0 2px 8px var(--accent-glow); }
.logo-mark span { filter: drop-shadow(0 0 2px rgba(0,0,0,.3)); }
.side-head .title { font-weight: 700; font-size: 13px; letter-spacing: -0.01em; }
.side-head .plus { width: 22px; height: 22px; border-radius: 6px; color: var(--text-dim); display: grid; place-items: center; }
.side-head .plus:hover { background: var(--hover); color: var(--text); }
.proj { margin-bottom: 4px; }
.proj-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 7px; cursor: pointer; }
.proj-row:hover { background: var(--hover); }
.proj-row.sel { background: var(--sel); }
.proj-row .chev { font-size: 9px; color: var(--text-dim); width: 8px; }
.proj-row .pname { font-size: 12.5px; font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.proj-row.sel .pname { color: var(--accent-text); }
.proj-row .count { font-size: 10.5px; font-weight: 600; color: var(--text-dim); background: var(--chip); padding: 1px 7px; border-radius: 20px; font-variant-numeric: tabular-nums; }
.proj-row .tools { display: none; gap: 2px; }
.proj-row:hover .tools { display: flex; }
.proj-row:hover .count { display: none; }
.tools button { width: 18px; height: 18px; border-radius: 4px; color: var(--text-dim); display: grid; place-items: center; font-size: 10px; }
.tools button:hover { background: var(--hover2); color: var(--text); }
.ses-list { padding: 1px 0 6px 4px; }
.ses { display: flex; align-items: center; gap: 9px; padding: 6px 8px; border-radius: 6px; cursor: pointer; }
.ses:hover { background: var(--hover); }
.ses .dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.ses .sname { font-size: 11.5px; color: var(--text-soft); flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); }
.ses .sid { font-size: 9.5px; color: var(--text-dim); font-family: var(--mono); }
/* topbar */
.crumb { display: flex; align-items: center; gap: 9px; }
.crumb .home { font-weight: 700; font-size: 13.5px; letter-spacing: -0.01em; }
.seg { font-size: 11px; font-weight: 600; padding: 4px 10px; border-radius: 7px; background: var(--chip); color: var(--text-soft); }
.seg.live { color: var(--accent-text); background: var(--accent-dim); }
.top-spacer { flex: 1; }
.top-search { display:flex; align-items:center; gap:8px; font-size:12px; color: var(--text-dim); background: var(--chip); padding: 7px 12px; border-radius: 9px; min-width: 200px; }
.top-search kbd { margin-left:auto; font-family: var(--mono); font-size: 10px; background: var(--hover2); padding: 1px 5px; border-radius: 4px; }
.icon-btn { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; color: var(--text-soft); position: relative; }
.icon-btn:hover { background: var(--hover); color: var(--text); }
.icon-btn .badge { position: absolute; top: 3px; right: 3px; width: 15px; height: 15px; border-radius: 50%; background: #f0b429; color: #1a1300; font-size: 9px; font-weight: 800; display: grid; place-items: center; }
.btn { padding: 8px 14px; border-radius: 9px; font-size: 12.5px; font-weight: 600; display: flex; align-items: center; gap: 7px; }
.btn-primary { background: var(--accent); color: #fff; box-shadow: 0 2px 10px var(--accent-glow); }
.btn-primary:hover { filter: brightness(1.1); }
.btn-ghost { color: var(--text-soft); box-shadow: inset 0 0 0 1px var(--border-strong); }
.btn-ghost:hover { background: var(--hover); color: var(--text); }
.btn-danger { color: #ff7a7a; box-shadow: inset 0 0 0 1px rgba(255,90,90,0.3); }
.btn-danger:hover { background: rgba(255,90,90,0.12); }
/* main / fleet board */
.board-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 20px; }
.board-head h1 { font-family: var(--display); font-weight: 800; font-size: 26px; letter-spacing: -0.03em; }
.board-head .sub { font-size: 12.5px; color: var(--text-dim); margin-top: 4px; }
.stat-rail { display: flex; gap: 8px; }
.stat { padding: 8px 14px; border-radius: 11px; background: var(--card); box-shadow: inset 0 0 0 1px var(--border); min-width: 78px; }
.stat .n { font-family: var(--display); font-weight: 800; font-size: 19px; font-variant-numeric: tabular-nums; line-height: 1; }
.stat .l { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 5px; }
.stat.hot .n { color: var(--agent); }
.cols { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
.card { background: var(--card); border-radius: var(--radius); box-shadow: inset 0 0 0 1px var(--border), var(--shadow); padding: 15px 16px; position: relative; overflow: hidden; }
.card::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background: var(--st-color); opacity: var(--rail-op, 1); }
.card .c-top { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.agent-ava { width: 30px; height: 30px; border-radius: 8px; background: var(--agent-bg); display: grid; place-items: center; font-size: 14px; flex-shrink: 0; box-shadow: inset 0 0 0 1px var(--agent-border); }
.c-id { flex: 1; min-width: 0; }
.c-id .branch { font-family: var(--mono); font-size: 11.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.c-id .meta { font-size: 10.5px; color: var(--text-dim); margin-top: 2px; }
.status-badge { display: inline-flex; align-items: center; gap: 6px; font-size: 10.5px; font-weight: 600; padding: 3px 9px 3px 7px; border-radius: 20px; background: var(--st-bg); color: var(--st-color); white-space: nowrap; }
.status-badge .sd { width: 6px; height: 6px; border-radius: 50%; background: var(--st-color); }
.status-badge.pulse .sd { animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%,100%{ opacity:1; box-shadow:0 0 0 0 var(--st-color);} 50%{ opacity:.5; box-shadow:0 0 0 4px transparent;} }
.c-line { font-size: 11.5px; color: var(--text-soft); line-height: 1.5; margin-bottom: 12px; min-height: 34px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 13px; }
.chip { font-size: 10px; font-weight: 600; padding: 3px 8px; border-radius: 6px; background: var(--chip); color: var(--text-soft); font-family: var(--mono); display:flex; align-items:center; gap:5px; }
.chip.ok { color: #5bd98a; background: rgba(60,200,120,0.12); }
.chip.bad { color: #ff8585; background: rgba(255,90,90,0.12); }
.chip.pr { color: #b78bff; background: rgba(150,110,255,0.14); }
.c-foot { display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--border); padding-top: 11px; }
.spark { display: flex; align-items: flex-end; gap: 2px; height: 16px; }
.spark i { width: 3px; background: var(--st-color); border-radius: 1px; opacity: 0.55; }
.c-foot .cost { font-family: var(--mono); font-size: 10.5px; color: var(--text-dim); }
.c-foot .cost b { color: var(--text-soft); font-weight: 600; }
/* status palette (shared semantic hues) */
.s-working { --st-color:#ff9e2c; --st-bg:rgba(255,158,44,0.14); }
.s-input { --st-color:#ffd23f; --st-bg:rgba(255,210,63,0.14); }
.s-stuck { --st-color:#ff5f5f; --st-bg:rgba(255,95,95,0.14); }
.s-review { --st-color:#36c5f0; --st-bg:rgba(54,197,240,0.14); }
.s-merge { --st-color:#3ddc84; --st-bg:rgba(61,220,132,0.14); }
.s-done { --st-color:#7a8aa8; --st-bg:rgba(122,138,168,0.14); }
.s-idle { --st-color:#6b7280; --st-bg:rgba(107,114,128,0.12); }
/* ============================================================
THEME A — REFINED (Linear-grade)
============================================================ */
#A .app {
--ui:"Schibsted Grotesk",sans-serif; --display:"Schibsted Grotesk",sans-serif; --mono:"JetBrains Mono",monospace;
--bg:#0e0f12; --bg-side:#0b0c0e; --bg-top:#0e0f12;
--card:#141519; --radius:13px; --shadow:0 1px 2px rgba(0,0,0,0.4);
--text:#eef0f4; --text-soft:#aab1bd; --text-dim:#6b7280;
--border:rgba(255,255,255,0.07); --border-strong:rgba(255,255,255,0.12);
--hover:rgba(255,255,255,0.05); --hover2:rgba(255,255,255,0.09); --sel:rgba(77,141,255,0.12);
--chip:rgba(255,255,255,0.06);
--accent:#4d8dff; --accent-text:#7eaaff; --accent-dim:rgba(77,141,255,0.16); --accent-glow:rgba(77,141,255,0.35);
--agent:#ff9e2c; --agent-bg:rgba(255,158,44,0.13); --agent-border:rgba(255,158,44,0.3);
}
/* ============================================================
THEME B — TERMINAL-CRAFT
============================================================ */
#B .app {
--ui:"Hanken Grotesk",sans-serif; --display:"Hanken Grotesk",sans-serif; --mono:"IBM Plex Mono",monospace;
--bg:#0c0d10; --bg-side:#090a0c; --bg-top:#0a0b0d;
--card:#101319; --radius:8px; --shadow:0 0 0 0 transparent;
--text:#dfe6e9; --text-soft:#9fb0bd; --text-dim:#5f7180;
--border:rgba(120,200,255,0.10); --border-strong:rgba(120,200,255,0.18);
--hover:rgba(120,200,255,0.06); --hover2:rgba(120,200,255,0.12); --sel:rgba(77,141,255,0.14);
--chip:rgba(120,200,255,0.07);
--accent:#2f7bf6; --accent-text:#5fa8ff; --accent-dim:rgba(77,141,255,0.16); --accent-glow:rgba(77,141,255,0.5);
--agent:#ffae3d; --agent-bg:rgba(255,174,61,0.12); --agent-border:rgba(255,174,61,0.32);
}
#B .app { position: relative; }
#B .main { background-image: repeating-linear-gradient(0deg, rgba(120,200,255,0.022) 0px, rgba(120,200,255,0.022) 1px, transparent 1px, transparent 3px); }
#B .card { box-shadow: inset 0 0 0 1px var(--border); }
#B .card::before { box-shadow: 0 0 12px var(--st-color); }
#B .logo-mark { box-shadow: 0 0 14px var(--accent-glow); }
#B .board-head h1 { font-family: var(--mono); font-weight: 600; letter-spacing: -0.02em; }
#B .board-head h1::before { content:" "; color: var(--accent-text); }
#B .status-badge { border-radius: 4px; box-shadow: 0 0 10px -2px var(--st-color); }
#B .status-badge .sd { box-shadow: 0 0 6px var(--st-color); }
#B .chip { border-radius:3px; }
#B .seg, #B .top-search, #B .btn { border-radius: 6px; }
/* ============================================================
THEME C — BOLD CONSOLE
============================================================ */
#C .app {
--ui:"Hanken Grotesk",sans-serif; --display:"Archivo",sans-serif; --mono:"Spline Sans Mono",monospace;
--bg:#070810; --bg-side:#05060c; --bg-top:#090b15;
--card:#0f1320; --radius:14px; --shadow:0 8px 24px -12px rgba(0,0,0,0.7);
--text:#f2f5ff; --text-soft:#a7b0c8; --text-dim:#626d8a;
--border:rgba(255,255,255,0.08); --border-strong:rgba(120,160,255,0.25);
--hover:rgba(120,160,255,0.08); --hover2:rgba(120,160,255,0.15); --sel:rgba(70,120,255,0.18);
--chip:rgba(120,160,255,0.08);
--accent:#3b6bff; --accent-text:#84a4ff; --accent-dim:rgba(70,120,255,0.2); --accent-glow:rgba(59,107,255,0.55);
--agent:#ff8c1a; --agent-bg:rgba(255,140,26,0.14); --agent-border:rgba(255,140,26,0.36);
}
#C .topbar { background: linear-gradient(180deg, #0d1020, #090b15); }
#C .sidebar { background: linear-gradient(180deg,#070810,#05060c); }
#C .board-head h1 { font-size: 32px; text-transform: uppercase; letter-spacing: -0.01em; }
#C .main { background:
radial-gradient(900px 400px at 80% -10%, rgba(59,107,255,0.12), transparent 60%),
radial-gradient(700px 360px at 0% 120%, rgba(255,140,26,0.06), transparent 55%); }
#C .card { box-shadow: inset 0 0 0 1px var(--border), var(--shadow); }
#C .card::before { width: 4px; }
#C .stat { background: linear-gradient(180deg, rgba(120,160,255,0.06), transparent); }
#C .stat .n { font-size: 22px; }
#C .status-badge { text-transform: uppercase; letter-spacing: 0.04em; font-size: 9.5px; }
#C .logo-mark { width: 24px; height: 24px; }
</style>
</head>
<body>
<div class="switcher">
<div class="brand">AO <b>·</b> Direction Previews</div>
<button class="tab active" data-t="A">A · Refined</button>
<button class="tab" data-t="B">B · Terminal-craft</button>
<button class="tab" data-t="C">C · Bold console</button>
<div class="note">blue = orchestrator · orange = agents · same screen, three treatments</div>
</div>
<div class="stage">
<div class="concept active" id="A"></div>
<div class="concept" id="B"></div>
<div class="concept" id="C"></div>
</div>
<script>
const PROJECTS = [
{ name:"integrator", sel:false, count:5, open:false, sessions:[] },
{ name:"Agent Orchestrator", sel:true, count:4, open:true, sessions:[
{ dot:"s-working", id:"ao-204", branch:"session/ao-204" },
{ dot:"s-review", id:"ao-201", branch:"docs/architecture-doc" },
{ dot:"s-idle", id:"ao-202", branch:"docs/architecture-doc-claude" },
{ dot:"s-merge", id:"ao-203", branch:"test/onboarding-harness" },
]},
{ name:"mercury", sel:false, count:2, open:false, sessions:[] },
{ name:"WorkOS", sel:false, count:0, open:false, sessions:[] },
{ name:"Eng Hiring", sel:false, count:0, open:false, sessions:[] },
];
const CARDS = [
{ st:"s-working", label:"Working", pulse:true, branch:"session/ao-204", id:"ao-204", model:"Opus 4.7",
line:"Building three design-direction previews for the dashboard fleet board…",
chips:[["PR #2051","pr"],["CI running","plain"]], cost:"$2.41", spark:[6,9,5,11,8,13,7,10,14,9], t:"now" },
{ st:"s-input", label:"Needs input", branch:"feat/int-6-api-key-auth", id:"int-6", model:"Sonnet 4.6",
line:"Awaiting approval to run database migration on staging.",
chips:[["PR #2044","pr"],["3 files","plain"]], cost:"$0.88", spark:[8,4,9,3,7,2,6,1,1,1], t:"2m" },
{ st:"s-stuck", label:"CI failed", branch:"security/cortex-sanitize", id:"int-8", model:"Opus 4.7",
line:"typecheck failed — 2 errors in plugin-registry.ts. Investigating.",
chips:[["PR #2049","pr"],["CI failed","bad"]], cost:"$3.10", spark:[10,7,12,6,9,4,5,3,2,1], t:"6m" },
{ st:"s-review", label:"In review", branch:"docs/architecture-doc", id:"ao-201", model:"Sonnet 4.6",
line:"2 review comments from @prateek — addressing nits in CROSS_PLATFORM.md.",
chips:[["PR #2038","pr"],["CI passed","ok"]], cost:"$1.20", spark:[5,8,6,9,7,10,6,8,5,7], t:"4m" },
{ st:"s-merge", label:"Mergeable", branch:"test/onboarding-harness", id:"ao-203", model:"Sonnet 4.6",
line:"All checks green, approved by 1 reviewer. Ready to merge.",
chips:[["PR #2042","pr"],["CI passed","ok"],["+1 approve","plain"]], cost:"$0.64", spark:[3,4,3,5,4,6,4,3,2,1], t:"11m" },
{ st:"s-idle", label:"Idle", branch:"docs/architecture-doc-claude", id:"ao-202", model:"Opus 4.7",
line:"No activity for 18m. Last action: pushed commit 2f9717f.",
chips:[["PR #2040","pr"],["CI passed","ok"]], cost:"$1.95", spark:[2,1,2,1,1,1,1,1,1,1], t:"18m" },
];
function spark(arr){ return '<div class="spark">'+arr.map(h=>`<i style="height:${h}px"></i>`).join('')+'</div>'; }
function chip(c){ const cls=c[1]==='plain'?'':c[1]; const dot=c[1]==='ok'?'✓ ':c[1]==='bad'?'✕ ':c[1]==='pr'?'⎇ ':''; return `<span class="chip ${cls}">${dot}${c[0]}</span>`; }
function sidebar(){
return PROJECTS.map(p=>{
const sess = p.open ? `<div class="ses-list">`+p.sessions.map(s=>
`<div class="ses"><span class="dot ${s.dot}" style="background:var(--st-color)"></span><span class="sname">${s.branch}</span><span class="sid">${s.id}</span></div>`
).join('')+`</div>` : '';
return `<div class="proj">
<div class="proj-row ${p.sel?'sel':''}">
<span class="chev">${p.open?'▾':'▸'}</span>
<span class="pname">${p.name}</span>
<span class="count">${p.count}</span>
<span class="tools"><button></button><button></button><button></button></span>
</div>${sess}
</div>`;
}).join('');
}
function card(c){
return `<div class="card ${c.st}" style="--st-color:var(--st-color)">
<div class="c-top">
<div class="agent-ava">🤖</div>
<div class="c-id"><div class="branch">${c.branch}</div><div class="meta">${c.id} · ${c.model} · ${c.t}</div></div>
<span class="status-badge ${c.pulse?'pulse':''}"><span class="sd"></span>${c.label}</span>
</div>
<div class="c-line">${c.line}</div>
<div class="chips">${c.chips.map(chip).join('')}</div>
<div class="c-foot">${spark(c.spark)}<span class="cost">cost <b>${c.cost}</b></span></div>
</div>`;
}
function render(){
['A','B','C'].forEach(t=>{
document.getElementById(t).innerHTML = `
<div class="app">
<aside class="sidebar">
<div class="side-head">
<div class="logo"><span class="logo-mark"><span>🪄</span></span><span class="title">Orchestrator</span></div>
<button class="plus">+</button>
</div>
${sidebar()}
</aside>
<header class="topbar">
<div class="crumb"><span class="home">Fleet</span><span class="seg live">● 6 active</span></div>
<div class="top-spacer"></div>
<div class="top-search">⌕ Search sessions, PRs, agents… <kbd>⌘K</kbd></div>
<button class="icon-btn">🔔<span class="badge">2</span></button>
<button class="btn btn-danger">Stop all</button>
<button class="btn btn-primary">🪄 New agent</button>
</header>
<main class="main">
<div class="board-head">
<div><h1>Fleet</h1><div class="sub">6 sessions across 3 projects · 1 needs your input · 1 ready to merge</div></div>
<div class="stat-rail">
<div class="stat hot"><div class="n">1</div><div class="l">Working</div></div>
<div class="stat"><div class="n">1</div><div class="l">Needs input</div></div>
<div class="stat"><div class="n">1</div><div class="l">Failing</div></div>
<div class="stat"><div class="n">1</div><div class="l">Mergeable</div></div>
</div>
</div>
<div class="cols">${CARDS.map(card).join('')}</div>
</main>
</div>`;
});
}
render();
document.querySelectorAll('.switcher .tab').forEach(b=>{
b.onclick=()=>{
document.querySelectorAll('.switcher .tab').forEach(x=>x.classList.remove('active'));
document.querySelectorAll('.concept').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
document.getElementById(b.dataset.t).classList.add('active');
};
});
</script>
</body>
</html>

View File

@ -0,0 +1,248 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AO — Kanban</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root{
--bg:#0a0b0d; --bg-side:#08090b; --bg-top:#0a0b0d;
--col:#0e0f12; /* column trough */
--card:#15171b; --card-hover:#191b20;
--ui:"Schibsted Grotesk",sans-serif; --mono:"JetBrains Mono",monospace;
--t1:#f4f5f7; --t2:#9ba1aa; --t3:#646a73; --t4:#444951;
--line:rgba(255,255,255,0.06); --line-2:rgba(255,255,255,0.10);
--blue:#4d8dff; --blue-soft:#7eaaff;
--orange:#f59f4c; --amber:#e8c14a; --red:#ef6b6b; --green:#74b98a;
--hover:rgba(255,255,255,0.04); --hover-2:rgba(255,255,255,0.07);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--t1);font-family:var(--ui);-webkit-font-smoothing:antialiased;overflow:hidden;font-size:14px}
button{font:inherit;cursor:pointer;border:none;background:none;color:inherit}
.app{height:100%;display:grid;grid-template-columns:256px 1fr;grid-template-rows:56px 1fr;
grid-template-areas:"side top" "side main"}
/* ---- sidebar (same restrained language) ---- */
.sidebar{grid-area:side;background:var(--bg-side);border-right:1px solid var(--line);overflow-y:auto;padding:16px 12px;display:flex;flex-direction:column}
.brand{display:flex;align-items:center;gap:10px;padding:2px 8px 20px}
.mark{width:26px;height:26px;border-radius:7px;background:#000;overflow:hidden;display:grid;place-items:center;box-shadow:inset 0 0 0 1px var(--line),0 2px 10px rgba(77,141,255,.18)}
.mark img{width:23px;height:23px;object-fit:contain;image-rendering:pixelated}
.brand .name{font-weight:700;font-size:14px;letter-spacing:-.015em}
.brand .name b{font-weight:400;color:var(--t3)}
.nav-label{font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.09em;color:var(--t4);padding:0 8px 8px;display:flex;justify-content:space-between}
.nav-label button{color:var(--t4);font-size:13px}
.proj{margin-bottom:1px}
.prow{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:5px;cursor:pointer}
.prow:hover{background:var(--hover)} .prow.sel{background:var(--hover-2)}
.prow .chev{font-size:8px;color:var(--t4);width:8px}
.prow .pn{font-size:13px;font-weight:500;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--t2)}
.prow.sel .pn{color:var(--t1);font-weight:600}
.prow .ct{font-size:11px;color:var(--t3);font-family:var(--mono)}
.slist{padding:2px 0 8px 5px;margin-left:8px;border-left:1px solid var(--line)}
.ses{display:flex;align-items:center;gap:10px;padding:5px 8px 5px 10px;border-radius:5px;cursor:pointer}
.ses:hover{background:var(--hover)}
.ses .dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:var(--t4)}
.ses .sn{font-size:12px;color:var(--t2);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:var(--mono)}
.ses .si{font-size:10px;color:var(--t4);font-family:var(--mono)}
.ses.act .dot{background:var(--orange);animation:breathe 2.4s ease-in-out infinite}
.side-foot{margin-top:auto;padding-top:14px;border-top:1px solid var(--line);display:flex;gap:2px}
.side-foot button{width:30px;height:30px;border-radius:6px;color:var(--t3);display:grid;place-items:center;font-size:14px}
.side-foot button:hover{background:var(--hover);color:var(--t1)}
/* ---- topbar ---- */
.topbar{grid-area:top;background:var(--bg-top);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:13px;padding:0 20px}
.crumb{display:flex;align-items:center;gap:10px}
.crumb .h{font-weight:600;font-size:14.5px;letter-spacing:-.01em}
.tabs{display:flex;gap:2px;margin-left:6px}
.tabs button{font-size:12.5px;font-weight:500;color:var(--t3);padding:5px 11px;border-radius:6px}
.tabs button.on{color:var(--t1);background:var(--hover-2)}
.tabs button:hover{color:var(--t1)}
.spacer{flex:1}
.pill{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--t2);padding:5px 11px;border-radius:6px;box-shadow:inset 0 0 0 1px var(--line)}
.pill .d{width:6px;height:6px;border-radius:50%;background:var(--orange);animation:breathe 2.4s ease-in-out infinite}
.ibtn{width:34px;height:34px;border-radius:7px;display:grid;place-items:center;color:var(--t2);position:relative}
.ibtn:hover{background:var(--hover);color:var(--t1)}
.ibtn .bdg{position:absolute;top:5px;right:5px;width:7px;height:7px;border-radius:50%;background:var(--amber);box-shadow:0 0 0 2px var(--bg-top)}
.btn{padding:8px 15px;border-radius:7px;font-size:13px;font-weight:600;display:flex;align-items:center;gap:8px}
.btn-primary{background:var(--blue);color:#fff} .btn-primary:hover{background:#5b97ff}
/* ---- board ---- */
.main{grid-area:main;overflow:hidden;display:flex;flex-direction:column;padding:22px 22px 0}
.bhead{display:flex;align-items:baseline;gap:12px;margin-bottom:18px;padding:0 2px}
.bhead h1{font-size:21px;font-weight:700;letter-spacing:-.025em}
.bhead .sub{font-size:12.5px;color:var(--t3)}
.board{flex:1;display:grid;grid-template-columns:repeat(4,minmax(280px,1fr));gap:16px;min-height:0;padding-bottom:14px}
/* borderless tinted troughs — one fill, no stroke. cards are the only bordered box.
each column carries a faint semantic top-glow in its own colour (consistent treatment). */
.column{display:flex;flex-direction:column;min-height:0;position:relative;border-radius:13px;
background:linear-gradient(180deg,rgba(var(--cg),.06),transparent 130px),rgba(255,255,255,.018);overflow:hidden}
.col-head{display:flex;align-items:center;gap:9px;padding:14px 15px 11px}
.col-head .cd{width:7px;height:7px;border-radius:50%;box-shadow:0 0 7px rgba(var(--cg),.6)}
.col-head .ct{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:rgb(var(--ctc,155,161,170))}
.col-head .cn{margin-left:auto;font-size:11px;color:var(--t3);font-family:var(--mono)}
/* column semantic palettes: --cg glow rgb, --ctc title rgb, .cd dot */
.c-work {--cg:245,159,76; --ctc:231,180,131} .c-work .cd{background:var(--orange)}
.c-attn {--cg:232,193,74; --ctc:227,207,141} .c-attn .cd{background:var(--amber)}
.c-rev {--cg:140,160,190; --ctc:160,170,185} .c-rev .cd{background:var(--t2)}
.c-ready{--cg:116,185,138; --ctc:150,200,168} .c-ready .cd{background:var(--green)}
.col-body{flex:1;overflow-y:auto;padding:0 11px 12px;display:flex;flex-direction:column;gap:10px;position:relative}
.col-body::-webkit-scrollbar{width:8px} .col-body::-webkit-scrollbar-thumb{background:var(--hover-2);border-radius:8px}
.empty{flex:1;display:grid;place-items:center;color:var(--t4);font-size:12px;text-align:center;padding:30px 16px;border:1px dashed var(--line);border-radius:8px}
/* ---- card (compact) ---- */
.k{background:var(--card);border-radius:7px;box-shadow:inset 0 0 0 1px var(--line);padding:12px 13px;cursor:pointer;transition:background .14s,box-shadow .14s}
.k:hover{background:var(--card-hover);box-shadow:inset 0 0 0 1px var(--line-2)}
.k-top{display:flex;align-items:center;gap:8px;margin-bottom:9px}
.k-id{font-family:var(--mono);font-size:10.5px;color:var(--t3)}
.k-stat{display:inline-flex;align-items:center;gap:6px;font-size:10.5px;font-weight:600;color:var(--t3)}
.k-stat .d{width:6px;height:6px;border-radius:50%;background:var(--t3)}
.k-stat.working{color:var(--orange)} .k-stat.working .d{background:var(--orange);animation:breathe 2.4s ease-in-out infinite}
.k-stat.input{color:var(--amber)} .k-stat.input .d{background:var(--amber)}
.k-stat.fail{color:var(--red)} .k-stat.fail .d{background:var(--red)}
.k-stat.changes{color:var(--amber)} .k-stat.changes .d{background:var(--amber)}
.k-stat.ready{color:var(--green)} .k-stat.ready .d{background:var(--green)}
.k-spacer{flex:1}
.k-term{font-size:10px;color:var(--t4);font-family:var(--mono);display:flex;align-items:center;gap:4px;padding:2px 6px;border-radius:5px;opacity:0;transition:.14s}
.k:hover .k-term{opacity:1} .k-term:hover{background:var(--hover-2);color:var(--t2)}
.k-title{font-size:13px;font-weight:500;line-height:1.42;color:var(--t1);letter-spacing:-.01em;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-bottom:10px}
.k-branch{font-family:var(--mono);font-size:11px;color:var(--t2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:10px;display:flex;align-items:center;gap:6px}
.k-branch .g{color:var(--t4);display:inline-flex;align-items:center}
.nav-label button,.side-foot button,.ibtn,.k-term{display:inline-flex;align-items:center;justify-content:center}
.k-foot{display:flex;align-items:center;gap:10px;font-size:10.5px;color:var(--t3);font-family:var(--mono);border-top:1px solid var(--line);padding-top:9px}
.k-foot .pr{color:var(--t2)}
.k-foot .ok{color:var(--green)} .k-foot .bad{color:var(--red)}
.k-foot .sep{flex:1}
@keyframes breathe{0%,100%{opacity:1}50%{opacity:.35}}
.donerow{display:flex;align-items:center;gap:10px;padding:11px 4px 16px;color:var(--t3);font-size:12px}
.donerow .chev{font-size:9px;color:var(--t4)}
.donerow .n{font-family:var(--mono);font-size:11px;color:var(--t4)}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand"><span class="mark"><img src="mascot.png" alt="AO" /></span><span class="name">Agent<b> / </b>Orchestrator</span></div>
<div class="nav-label">Projects <button><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button></div>
<div class="proj"><div class="prow"><span class="chev"></span><span class="pn">integrator</span><span class="ct">5</span></div></div>
<div class="proj">
<div class="prow sel"><span class="chev"></span><span class="pn">Agent Orchestrator</span><span class="ct">4</span></div>
<div class="slist">
<div class="ses act"><span class="dot"></span><span class="sn">session/ao-204</span><span class="si">ao-204</span></div>
<div class="ses"><span class="dot"></span><span class="sn">docs/architecture-doc</span><span class="si">ao-201</span></div>
<div class="ses"><span class="dot"></span><span class="sn">docs/architecture-doc-claude</span><span class="si">ao-202</span></div>
<div class="ses"><span class="dot"></span><span class="sn">test/onboarding-harness</span><span class="si">ao-203</span></div>
</div>
</div>
<div class="proj"><div class="prow"><span class="chev"></span><span class="pn">mercury</span><span class="ct">2</span></div></div>
<div class="proj"><div class="prow"><span class="chev"></span><span class="pn">WorkOS</span><span class="ct">0</span></div></div>
<div class="proj"><div class="prow"><span class="chev"></span><span class="pn">Eng Hiring</span><span class="ct">0</span></div></div>
<div class="side-foot">
<button><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/></svg></button>
<button><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="4"/><path d="m8 12 2.5 2.5L16 9"/></svg></button>
<button><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="20" y1="8" y2="8"/><circle cx="15" cy="8" r="2.3"/><line x1="4" x2="20" y1="16" y2="16"/><circle cx="9" cy="16" r="2.3"/></svg></button>
<button><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/></svg></button>
</div>
</aside>
<header class="topbar">
<div class="crumb"><span class="h">Agent Orchestrator</span></div>
<div class="tabs"><button class="on">Board</button><button>Reviews</button><button>Activity</button></div>
<div class="spacer"></div>
<div class="pill"><span class="d"></span>2 working</div>
<button class="ibtn"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg><span class="bdg"></span></button>
<button class="btn btn-primary">New agent</button>
</header>
<main class="main">
<div class="bhead"><h1>Board</h1><span class="sub">Live agent sessions flowing from work → review → merge.</span></div>
<div class="board">
<!-- WORKING -->
<section class="column c-work">
<div class="col-head"><span class="cd"></span><span class="ct">Working</span><span class="cn">2</span></div>
<div class="col-body">
<div class="k">
<div class="k-top"><span class="k-stat working"><span class="d"></span>Working</span><span class="k-spacer"></span><span class="k-id">ao-204</span><span class="k-term"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>terminal</span></div>
<div class="k-title">Brainstorm the design language &amp; component library for the dashboard</div>
<div class="k-branch"><span class="g"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="4" x2="6" y2="14"/><circle cx="6" cy="17" r="2.3"/><circle cx="18" cy="7" r="2.3"/><path d="M18 9.3a8 8 0 0 1-8 8"/></svg></span>session/ao-204</div>
<div class="k-foot"><span class="pr">PR #2051</span><span>CI running</span><span class="sep"></span><span>$2.41</span></div>
</div>
<div class="k">
<div class="k-top"><span class="k-stat working"><span class="d"></span>Working</span><span class="k-spacer"></span><span class="k-id">int-7</span><span class="k-term"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>terminal</span></div>
<div class="k-title">Wire internal API behind the ALB with Tailscale-only ingress</div>
<div class="k-branch"><span class="g"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="4" x2="6" y2="14"/><circle cx="6" cy="17" r="2.3"/><circle cx="18" cy="7" r="2.3"/><path d="M18 9.3a8 8 0 0 1-8 8"/></svg></span>feat/internal-api-alb-tailscale</div>
<div class="k-foot"><span class="t4">drafting PR…</span><span class="sep"></span><span>$1.07</span></div>
</div>
</div>
</section>
<!-- NEEDS YOU -->
<section class="column c-attn">
<div class="col-head"><span class="cd"></span><span class="ct">Needs you</span><span class="cn">2</span></div>
<div class="col-body">
<div class="k">
<div class="k-top"><span class="k-stat input"><span class="d"></span>Needs input</span><span class="k-spacer"></span><span class="k-id">int-6</span><span class="k-term"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>terminal</span></div>
<div class="k-title">Add API-key auth to the integrator service</div>
<div class="k-branch"><span class="g"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="4" x2="6" y2="14"/><circle cx="6" cy="17" r="2.3"/><circle cx="18" cy="7" r="2.3"/><path d="M18 9.3a8 8 0 0 1-8 8"/></svg></span>feat/int-6-api-key-auth</div>
<div class="k-foot"><span class="pr">PR #2044</span><span>approve migration?</span><span class="sep"></span><span>$0.88</span></div>
</div>
<div class="k">
<div class="k-top"><span class="k-stat fail"><span class="d"></span>CI failed</span><span class="k-spacer"></span><span class="k-id">int-8</span><span class="k-term"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>terminal</span></div>
<div class="k-title">Sanitize tool output in the Cortex security pipeline</div>
<div class="k-branch"><span class="g"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="4" x2="6" y2="14"/><circle cx="6" cy="17" r="2.3"/><circle cx="18" cy="7" r="2.3"/><path d="M18 9.3a8 8 0 0 1-8 8"/></svg></span>security/cortex-sanitize-tool-output</div>
<div class="k-foot"><span class="pr">PR #2049</span><span class="bad">2 checks failed</span><span class="sep"></span><span>$3.10</span></div>
</div>
</div>
</section>
<!-- IN REVIEW -->
<section class="column c-rev">
<div class="col-head"><span class="cd"></span><span class="ct">In review</span><span class="cn">2</span></div>
<div class="col-body">
<div class="k">
<div class="k-top"><span class="k-stat"><span class="d"></span>Review pending</span><span class="k-spacer"></span><span class="k-id">ao-201</span><span class="k-term"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>terminal</span></div>
<div class="k-title">Produce a high-quality HTML architecture design doc</div>
<div class="k-branch"><span class="g"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="4" x2="6" y2="14"/><circle cx="6" cy="17" r="2.3"/><circle cx="18" cy="7" r="2.3"/><path d="M18 9.3a8 8 0 0 1-8 8"/></svg></span>docs/architecture-doc</div>
<div class="k-foot"><span class="pr">PR #2038</span><span class="ok">✓ CI passed</span><span class="sep"></span><span>$1.20</span></div>
</div>
<div class="k">
<div class="k-top"><span class="k-stat changes"><span class="d"></span>Changes req.</span><span class="k-spacer"></span><span class="k-id">mer-43</span><span class="k-term"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>terminal</span></div>
<div class="k-title">Auth RCA improvements from the incident review</div>
<div class="k-branch"><span class="g"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="4" x2="6" y2="14"/><circle cx="6" cy="17" r="2.3"/><circle cx="18" cy="7" r="2.3"/><path d="M18 9.3a8 8 0 0 1-8 8"/></svg></span>docs/auth-rca-improvements</div>
<div class="k-foot"><span class="pr">PR #2031</span><span>3 comments</span><span class="sep"></span><span>$0.74</span></div>
</div>
</div>
</section>
<!-- READY -->
<section class="column c-ready">
<div class="col-head"><span class="cd"></span><span class="ct">Ready to merge</span><span class="cn">1</span></div>
<div class="col-body">
<div class="k">
<div class="k-top"><span class="k-stat ready"><span class="d"></span>Mergeable</span><span class="k-spacer"></span><span class="k-id">ao-203</span><span class="k-term"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>terminal</span></div>
<div class="k-title">Build an end-to-end onboarding test for the published npm package</div>
<div class="k-branch"><span class="g"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="4" x2="6" y2="14"/><circle cx="6" cy="17" r="2.3"/><circle cx="18" cy="7" r="2.3"/><path d="M18 9.3a8 8 0 0 1-8 8"/></svg></span>test/onboarding-harness</div>
<div class="k-foot"><span class="pr">PR #2042</span><span class="ok">✓ approved</span><span class="sep"></span><span>$0.64</span></div>
</div>
</div>
</section>
</div>
<div class="donerow"><span class="chev"></span>Done / Terminated<span class="n">· 32</span></div>
</main>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AO — Orchestrator button icon exploration</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root{
--bg:#0a0b0d; --panel:#0e0f12; --card:#15171b;
--ui:"Schibsted Grotesk",sans-serif; --mono:"JetBrains Mono",monospace;
--t1:#f4f5f7; --t2:#9ba1aa; --t3:#646a73; --t4:#444951;
--line:rgba(255,255,255,0.06); --line-2:rgba(255,255,255,0.10);
--blue:#4d8dff; --hover:rgba(255,255,255,0.04);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--t1);font-family:var(--ui);-webkit-font-smoothing:antialiased;padding:38px 44px 80px}
h1{font-size:21px;font-weight:700;letter-spacing:-.025em}
.lede{font-size:13px;color:var(--t3);margin:7px 0 30px;max-width:680px;line-height:1.55}
.lede b{color:var(--t2);font-weight:600}
.grp{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.09em;color:var(--t4);margin:30px 0 15px;padding-bottom:9px;border-bottom:1px solid var(--line)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(212px,1fr));gap:13px}
.cell{background:var(--card);border-radius:10px;box-shadow:inset 0 0 0 1px var(--line);padding:18px 16px 14px;display:flex;flex-direction:column;align-items:center;gap:13px}
.cell:hover{box-shadow:inset 0 0 0 1px var(--line-2)}
.btn{padding:8px 14px;border-radius:7px;font-size:12.5px;font-weight:600;display:inline-flex;align-items:center;gap:7px;background:var(--blue);color:#fff;cursor:pointer}
.btn:hover{background:#5b97ff}
.cap{text-align:center}
.cap .nm{font-size:12px;font-weight:600;color:var(--t1)}
.cap .ds{font-size:11px;color:var(--t3);margin-top:3px;line-height:1.4}
.cur{box-shadow:inset 0 0 0 1px rgba(77,141,255,.5)}
.badge{font-family:var(--mono);font-size:9px;color:var(--blue);background:rgba(77,141,255,.14);padding:1px 6px;border-radius:4px;margin-top:7px;display:inline-block}
/* icon-only + mascot variants */
.ibtn{width:36px;height:36px;border-radius:8px;background:var(--blue);display:grid;place-items:center;color:#fff;cursor:pointer}
.mtile{width:18px;height:18px;border-radius:4px;background:#000;overflow:hidden;display:grid;place-items:center}
.mtile img{width:16px;height:16px;image-rendering:pixelated}
</style>
</head>
<body>
<h1>Orchestrator button — icon candidates</h1>
<p class="lede">The action spawns &amp; conducts parallel agents. Two honest metaphor families: <b>conducting / magic</b> (ties to the blue-wand mascot lore) and <b>coordinating many nodes</b> (shows the fleet it manages). All shown in the real blue primary style.</p>
<div class="grp">Conducting / magic</div>
<div class="grid" id="g1"></div>
<div class="grp">Coordinating many agents</div>
<div class="grid" id="g2"></div>
<div class="grp">Other / literal</div>
<div class="grid" id="g3"></div>
<div class="grp">Treatment variants (icon-only · mascot)</div>
<div class="grid" id="g4"></div>
<script>
const S = (p,w=15)=>`<svg width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
const conducting = [
{nm:"Wand + sparkles", ds:"current — conductor's wand", cur:true,
ic:`<path d="m4 20 8.5-8.5"/><path d="M15 4.5 16 2l2.5 1L16 4l-1 2.5z"/><path d="m18 9 .5 2 2 .5-2 .5-.5 2-.5-2-2-.5 2-.5z"/>`},
{nm:"Wand (single)", ds:"cleaner, one sparkle at tip",
ic:`<path d="m4 20 10-10"/><path d="M14.5 5.5 16 2.5 17.5 5.5 20.5 7 17.5 8.5 16 11.5 14.5 8.5 11.5 7z"/>`},
{nm:"Sparkles", ds:"magic / auto — no stick",
ic:`<path d="M11 3 12.7 7.3 17 9 12.7 10.7 11 15 9.3 10.7 5 9 9.3 7.3z"/><path d="M18 14l.8 2.2L21 17l-2.2.8L18 20l-.8-2.2L15 17l2.2-.8z"/>`},
{nm:"Conductor baton", ds:"baton with tip — literal conducting",
ic:`<line x1="4.5" y1="19.5" x2="16" y2="8"/><circle cx="18" cy="6" r="2.4"/>`},
{nm:"Zap", ds:"fast spawn / power",
ic:`<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>`},
];
const coordinating = [
{nm:"Org chart", ds:"one conductor → many agents",
ic:`<rect x="9" y="2.5" width="6" height="5" rx="1.3"/><rect x="2.5" y="16.5" width="6" height="5" rx="1.3"/><rect x="15.5" y="16.5" width="6" height="5" rx="1.3"/><path d="M12 7.5v4M5.5 16.5v-2.5h13v2.5"/>`},
{nm:"Git fork", ds:"parallel branches / worktrees",
ic:`<circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="6" r="2.4"/><circle cx="12" cy="18" r="2.4"/><path d="M6 8.4v1.1a2.5 2.5 0 0 0 2.5 2.5h7A2.5 2.5 0 0 0 18 9.5V8.4M12 12v3.6"/>`},
{nm:"Hub / radial", ds:"central node, spokes to agents",
ic:`<circle cx="12" cy="12" r="2.6"/><circle cx="12" cy="4" r="1.8"/><circle cx="12" cy="20" r="1.8"/><circle cx="4.5" cy="8" r="1.8"/><circle cx="19.5" cy="8" r="1.8"/><path d="M12 9.4V5.8M12 14.6v3.6M9.8 10.7 6 8.9M14.2 10.7 18 8.9"/>`},
{nm:"Grid (fleet)", ds:"a board of agents",
ic:`<rect x="3" y="3" width="7.5" height="7.5" rx="1.6"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.6"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.6"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.6"/>`},
{nm:"Layers", ds:"stacked parallel work",
ic:`<path d="m12 3 9 5-9 5-9-5z"/><path d="m3 13 9 5 9-5"/>`},
];
const other = [
{nm:"Command", ds:"power-user / control surface",
ic:`<path d="M9 3a3 3 0 1 1-3 3v12a3 3 0 1 1 3-3h6a3 3 0 1 1-3 3V6a3 3 0 1 1 3 3H9"/>`},
{nm:"Compass", ds:"steer / direct the fleet",
ic:`<circle cx="12" cy="12" r="9"/><path d="m15.5 8.5-2 5-5 2 2-5z"/>`},
{nm:"Cpu / agent core", ds:"the brain doing the work",
ic:`<rect x="6" y="6" width="12" height="12" rx="2"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/><path d="M9 2.5v2M15 2.5v2M9 19.5v2M15 19.5v2M2.5 9h2M2.5 15h2M19.5 9h2M19.5 15h2"/>`},
{nm:"Play (start)", ds:"kick off agents",
ic:`<circle cx="12" cy="12" r="9"/><path d="M10 8.5 16 12l-6 3.5z"/>`},
{nm:"Bot", ds:"literal agent face",
ic:`<rect x="4" y="8" width="16" height="11" rx="2.5"/><path d="M12 4.5V8"/><circle cx="12" cy="3.5" r="1.3"/><circle cx="9" cy="13.5" r="1"/><circle cx="15" cy="13.5" r="1"/>`},
];
function render(id, arr){
document.getElementById(id).innerHTML = arr.map(o=>`
<div class="cell">
<button class="btn ${o.cur?'cur':''}">${S(o.ic)}Orchestrator</button>
<div class="cap"><div class="nm">${o.nm}</div><div class="ds">${o.ds}</div>
${o.cur?'<span class="badge">CURRENT</span>':''}</div>
</div>`).join('');
}
render('g1', conducting);
render('g2', coordinating);
render('g3', other);
// treatment variants
document.getElementById('g4').innerHTML = `
<div class="cell">
<button class="ibtn">${S(`<path d="m4 20 8.5-8.5"/><path d="M15 4.5 16 2l2.5 1L16 4l-1 2.5z"/><path d="m18 9 .5 2 2 .5-2 .5-.5 2-.5-2-2-.5 2-.5z"/>`,17)}</button>
<div class="cap"><div class="nm">Icon-only (wand)</div><div class="ds">label lives in a tooltip; saves topbar width</div></div>
</div>
<div class="cell">
<button class="btn"><span class="mtile"><img src="mascot.png" alt=""/></span>Orchestrator</button>
<div class="cap"><div class="nm">Mascot chip</div><div class="ds">most literal — but black tile fights the blue fill</div></div>
</div>
<div class="cell">
<button class="btn" style="background:#15171b;box-shadow:inset 0 0 0 1px rgba(77,141,255,.4);color:#7eaaff">${S(`<path d="m4 20 8.5-8.5"/><path d="M15 4.5 16 2l2.5 1L16 4l-1 2.5z"/><path d="m18 9 .5 2 2 .5-2 .5-.5 2-.5-2-2-.5 2-.5z"/>`)}Orchestrator</button>
<div class="cap"><div class="nm">Outline (not filled)</div><div class="ds">quieter — if Kill should be the only solid button</div></div>
</div>`;
</script>
</body>
</html>

View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AO — Org-chart icon refinement</title>
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root{--bg:#0a0b0d;--card:#15171b;--t1:#f4f5f7;--t2:#9ba1aa;--t3:#646a73;--t4:#444951;
--line:rgba(255,255,255,0.06);--line-2:rgba(255,255,255,0.10);--blue:#4d8dff;}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--t1);font-family:"Schibsted Grotesk",sans-serif;-webkit-font-smoothing:antialiased;padding:40px 46px 80px}
h1{font-size:21px;font-weight:700;letter-spacing:-.025em}
.lede{font-size:13px;color:var(--t3);margin:7px 0 30px;max-width:680px;line-height:1.55}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
.cell{background:var(--card);border-radius:11px;box-shadow:inset 0 0 0 1px var(--line);padding:20px 18px 16px;display:flex;flex-direction:column;align-items:center;gap:16px}
.cell.cur{box-shadow:inset 0 0 0 1px rgba(77,141,255,.45)}
.big{width:58px;height:58px;border-radius:13px;background:#101216;box-shadow:inset 0 0 0 1px var(--line);display:grid;place-items:center;color:var(--t1)}
.btn{padding:8px 14px;border-radius:7px;font-size:12.5px;font-weight:600;display:inline-flex;align-items:center;gap:7px;background:var(--blue);color:#fff;cursor:pointer}
.btn:hover{background:#5b97ff}
.cap{text-align:center}
.cap .nm{font-size:12.5px;font-weight:600}
.cap .ds{font-size:11px;color:var(--t3);margin-top:3px;line-height:1.4}
.badge{font-family:"JetBrains Mono",monospace;font-size:9px;color:var(--blue);background:rgba(77,141,255,.14);padding:1px 6px;border-radius:4px;margin-top:8px;display:inline-block}
</style>
</head>
<body>
<h1>Org-chart icon — refined variants</h1>
<p class="lede">One orchestrator conducting many agents. Tuned geometry, symmetric spacing, clean elbow/bus connectors, rounded joins. Shown large (detail) and at button size (truth).</p>
<div class="grid" id="g"></div>
<script>
const big = p => `<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
const sm = p => `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
const V = [
{nm:"1→3 cards", cur:true, ds:"parent + 3 agent-cards on a bus — echoes the kanban cards",
ic:`<rect x="9" y="2.5" width="6" height="4.5" rx="1.4"/><rect x="2.5" y="17" width="6" height="4.5" rx="1.4"/><rect x="9" y="17" width="6" height="4.5" rx="1.4"/><rect x="15.5" y="17" width="6" height="4.5" rx="1.4"/><path d="M12 7v4.5M5.5 17v-5.5h13V17M12 11.5V17"/>`},
{nm:"1→2 cards", ds:"calmer, two children",
ic:`<rect x="9" y="2.5" width="6" height="4.5" rx="1.4"/><rect x="3" y="17" width="6" height="4.5" rx="1.4"/><rect x="15" y="17" width="6" height="4.5" rx="1.4"/><path d="M12 7v4.5M6 17v-5.5h12V17"/>`},
{nm:"1→3 dots", ds:"lighter — round nodes for the fleet",
ic:`<circle cx="12" cy="4.6" r="2.1"/><circle cx="5" cy="19.4" r="2.1"/><circle cx="12" cy="19.4" r="2.1"/><circle cx="19" cy="19.4" r="2.1"/><path d="M12 6.7v5.1M5 17.3v-5.5h14v5.5M12 11.8v5.5"/>`},
{nm:"1→2 dots", ds:"minimal",
ic:`<circle cx="12" cy="5" r="2.3"/><circle cx="6" cy="19" r="2.3"/><circle cx="18" cy="19" r="2.3"/><path d="M12 7.3v4.5M6 16.7v-4.9h12v4.9"/>`},
{nm:"1→3 radial", ds:"fans out from the conductor (no bus)",
ic:`<rect x="9.5" y="2.5" width="5" height="4.5" rx="1.3"/><rect x="2.5" y="17" width="5" height="4.5" rx="1.3"/><rect x="9.5" y="17" width="5" height="4.5" rx="1.3"/><rect x="16.5" y="17" width="5" height="4.5" rx="1.3"/><path d="M11 7 6 17M12 7.2V17M13 7 18 17"/>`},
{nm:"Parent dot → card row", ds:"conductor as a node, agents as cards",
ic:`<circle cx="12" cy="4.5" r="2.2"/><rect x="2.5" y="16.5" width="5.5" height="5" rx="1.3"/><rect x="9.25" y="16.5" width="5.5" height="5" rx="1.3"/><rect x="16" y="16.5" width="5.5" height="5" rx="1.3"/><path d="M12 6.7v4.8M5.25 16.5v-5h13.5v5M12 11.5v5"/>`},
];
document.getElementById('g').innerHTML = V.map((v,i)=>`
<div class="cell ${v.cur?'cur':''}">
<div class="big">${big(v.ic)}</div>
<button class="btn">${sm(v.ic)}Orchestrator</button>
<div class="cap"><div class="nm">${i+1}. ${v.nm}</div><div class="ds">${v.ds}</div>
${v.cur?'<span class="badge">APPLIED TO SESSION PAGE</span>':''}</div>
</div>`).join('');
</script>
</body>
</html>

View File

@ -0,0 +1,270 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AO — Refined</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
:root{
/* surfaces — neutral, faint cool tint, three quiet levels */
--bg:#0b0c0e;
--bg-side:#0a0b0d;
--bg-top:#0b0c0e;
--card:#121316;
--card-hover:#16171b;
/* type */
--ui:"Schibsted Grotesk",sans-serif;
--mono:"JetBrains Mono",monospace;
--t1:#f4f5f7; /* primary */
--t2:#9ba1aa; /* secondary */
--t3:#646a73; /* dim */
--t4:#444951; /* faint */
/* lines */
--line:rgba(255,255,255,0.06);
--line-2:rgba(255,255,255,0.10);
/* the ONLY accents */
--blue:#4d8dff; /* orchestrator / you */
--blue-soft:#7eaaff;
--blue-dim:rgba(77,141,255,0.14);
--orange:#f59f4c; /* a working agent — the one 'alive' color */
--amber:#e8c14a; /* needs your input */
--red:#ef6b6b; /* failing */
--hover:rgba(255,255,255,0.04);
--hover-2:rgba(255,255,255,0.07);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--t1);font-family:var(--ui);-webkit-font-smoothing:antialiased;overflow:hidden;font-size:14px}
button{font:inherit;cursor:pointer;border:none;background:none;color:inherit}
.app{height:100%;display:grid;grid-template-columns:256px 1fr;grid-template-rows:56px 1fr;
grid-template-areas:"side top" "side main"}
/* ---------------- sidebar ---------------- */
.sidebar{grid-area:side;background:var(--bg-side);border-right:1px solid var(--line);overflow-y:auto;padding:16px 12px;display:flex;flex-direction:column}
.brand{display:flex;align-items:center;gap:10px;padding:2px 8px 20px}
.mark{width:24px;height:24px;border-radius:6px;background:linear-gradient(155deg,#5b97ff,#3b6bff);display:grid;place-items:center;font-size:13px;box-shadow:0 2px 10px rgba(77,141,255,0.3)}
.brand .name{font-weight:700;font-size:14px;letter-spacing:-0.015em}
.brand .name b{font-weight:400;color:var(--t3)}
.nav-label{font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:0.09em;color:var(--t4);padding:0 8px 8px;display:flex;justify-content:space-between;align-items:center}
.nav-label button{color:var(--t4);font-size:13px}
.nav-label button:hover{color:var(--t2)}
.proj{margin-bottom:1px}
.prow{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:5px;cursor:pointer}
.prow:hover{background:var(--hover)}
.prow.sel{background:var(--hover-2)}
.prow .chev{font-size:8px;color:var(--t4);width:8px;transition:.15s}
.prow .pn{font-size:13px;font-weight:500;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--t2)}
.prow.sel .pn{color:var(--t1);font-weight:600}
.prow .ct{font-size:11px;color:var(--t3);font-variant-numeric:tabular-nums;font-family:var(--mono)}
.prow .tools{display:none;gap:1px}
.prow:hover .tools{display:flex}
.prow:hover .ct{display:none}
.tools button{width:20px;height:20px;border-radius:5px;color:var(--t4);display:grid;place-items:center;font-size:11px}
.tools button:hover{background:var(--hover-2);color:var(--t1)}
.slist{padding:2px 0 8px 5px;margin-left:8px;border-left:1px solid var(--line)}
.ses{display:flex;align-items:center;gap:10px;padding:5px 8px 5px 10px;border-radius:6px;cursor:pointer}
.ses:hover{background:var(--hover)}
.ses .dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:var(--t4)}
.ses .sn{font-size:12px;color:var(--t2);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:var(--mono)}
.ses .si{font-size:10px;color:var(--t4);font-family:var(--mono)}
.ses.act .dot{background:var(--orange)}
.ses.act .dot{animation:breathe 2.4s ease-in-out infinite}
.side-foot{margin-top:auto;padding-top:14px;border-top:1px solid var(--line);display:flex;gap:2px}
.side-foot button{width:30px;height:30px;border-radius:7px;color:var(--t3);display:grid;place-items:center;font-size:14px}
.side-foot button:hover{background:var(--hover);color:var(--t1)}
/* ---------------- topbar ---------------- */
.topbar{grid-area:top;background:var(--bg-top);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;padding:0 20px}
.crumb{display:flex;align-items:center;gap:11px}
.crumb .h{font-weight:600;font-size:14.5px;letter-spacing:-0.01em}
.crumb .meta{font-size:12px;color:var(--t3)}
.spacer{flex:1}
.search{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--t3);background:transparent;box-shadow:inset 0 0 0 1px var(--line);padding:7px 12px;border-radius:6px;min-width:240px}
.search:hover{box-shadow:inset 0 0 0 1px var(--line-2)}
.search kbd{margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--t4);background:var(--hover-2);padding:2px 6px;border-radius:5px}
.ibtn{width:34px;height:34px;border-radius:7px;display:grid;place-items:center;color:var(--t2);position:relative}
.ibtn:hover{background:var(--hover);color:var(--t1)}
.ibtn .bdg{position:absolute;top:4px;right:4px;width:7px;height:7px;border-radius:50%;background:var(--amber);box-shadow:0 0 0 2px var(--bg-top)}
.btn{padding:8px 15px;border-radius:7px;font-size:13px;font-weight:600;display:flex;align-items:center;gap:8px;letter-spacing:-0.005em}
.btn-primary{background:var(--blue);color:#fff}
.btn-primary:hover{background:#5b97ff}
.btn-ghost{color:var(--t2);box-shadow:inset 0 0 0 1px var(--line-2)}
.btn-ghost:hover{background:var(--hover);color:var(--t1)}
/* ---------------- main / fleet ---------------- */
.main{grid-area:main;overflow-y:auto;padding:28px 30px 60px}
.head{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:26px}
.head h1{font-size:23px;font-weight:700;letter-spacing:-0.025em}
.head .sub{font-size:13px;color:var(--t3);margin-top:6px}
.head .sub b{color:var(--t2);font-weight:600}
/* quiet summary — grayscale, only attention counts get color */
.summary{display:flex;gap:24px;align-items:center}
.sm{display:flex;flex-direction:column;align-items:flex-end}
.sm .n{font-size:20px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1;letter-spacing:-0.02em}
.sm .l{font-size:11px;color:var(--t3);margin-top:5px}
.sm.attn .n{color:var(--amber)}
.sm.work .n{color:var(--orange)}
.sm-div{width:1px;height:30px;background:var(--line)}
.group-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--t4);margin:2px 0 13px}
.cols{display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:12px;margin-bottom:30px}
.card{background:var(--card);border-radius:8px;box-shadow:inset 0 0 0 1px var(--line);padding:16px 17px;cursor:pointer;transition:background .15s,box-shadow .15s}
.card:hover{background:var(--card-hover);box-shadow:inset 0 0 0 1px var(--line-2)}
.c-top{display:flex;align-items:center;gap:11px;margin-bottom:13px}
.c-id{flex:1;min-width:0}
.c-id .br{font-family:var(--mono);font-size:12.5px;font-weight:500;color:var(--t1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-0.01em}
.c-id .mt{font-size:11px;color:var(--t3);margin-top:3px;font-variant-numeric:tabular-nums}
/* status: a dot + label. neutral unless it needs attention. */
.stat{display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:500;color:var(--t2);white-space:nowrap}
.stat .d{width:7px;height:7px;border-radius:50%;background:var(--t3)}
.stat.working{color:var(--orange)} .stat.working .d{background:var(--orange);animation:breathe 2.4s ease-in-out infinite}
.stat.input{color:var(--amber)} .stat.input .d{background:var(--amber)}
.stat.fail{color:var(--red)} .stat.fail .d{background:var(--red)}
@keyframes breathe{0%,100%{opacity:1}50%{opacity:.35}}
.c-line{font-size:12.5px;color:var(--t2);line-height:1.55;margin-bottom:14px;min-height:39px}
.c-foot{display:flex;align-items:center;gap:14px;font-size:11px;color:var(--t3);font-family:var(--mono)}
.c-foot .x{display:flex;align-items:center;gap:6px}
.c-foot .x b{color:var(--t2);font-weight:500}
.c-foot .sep{flex:1}
.c-foot .ok{color:#7fb88a}
.c-foot .bad{color:var(--red)}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand"><span class="mark">🪄</span><span class="name">Agent<b> / </b>Orchestrator</span></div>
<div class="nav-label">Projects <button>+</button></div>
<div class="proj">
<div class="prow"><span class="chev"></span><span class="pn">integrator</span><span class="ct">5</span>
<span class="tools"><button></button><button></button></span></div>
</div>
<div class="proj">
<div class="prow sel"><span class="chev"></span><span class="pn">Agent Orchestrator</span><span class="ct">4</span>
<span class="tools"><button></button><button></button></span></div>
<div class="slist">
<div class="ses act"><span class="dot"></span><span class="sn">session/ao-204</span><span class="si">ao-204</span></div>
<div class="ses"><span class="dot"></span><span class="sn">docs/architecture-doc</span><span class="si">ao-201</span></div>
<div class="ses"><span class="dot"></span><span class="sn">docs/architecture-doc-claude</span><span class="si">ao-202</span></div>
<div class="ses"><span class="dot"></span><span class="sn">test/onboarding-harness</span><span class="si">ao-203</span></div>
</div>
</div>
<div class="proj">
<div class="prow"><span class="chev"></span><span class="pn">mercury</span><span class="ct">2</span>
<span class="tools"><button></button><button></button></span></div>
</div>
<div class="proj">
<div class="prow"><span class="chev"></span><span class="pn">WorkOS</span><span class="ct">0</span>
<span class="tools"><button></button><button></button></span></div>
</div>
<div class="proj">
<div class="prow"><span class="chev"></span><span class="pn">Eng Hiring</span><span class="ct">0</span>
<span class="tools"><button></button><button></button></span></div>
</div>
<div class="side-foot"><button></button><button></button><button></button><button></button></div>
</aside>
<header class="topbar">
<div class="crumb"><span class="h">Fleet</span><span class="meta">6 sessions · 3 projects</span></div>
<div class="spacer"></div>
<div class="search"><span>Search…</span><kbd>⌘K</kbd></div>
<button class="ibtn">🔔<span class="bdg"></span></button>
<button class="btn btn-ghost">Stop all</button>
<button class="btn btn-primary">New agent</button>
</header>
<main class="main">
<div class="head">
<div>
<h1>Fleet</h1>
<div class="sub"><b>1 agent working</b> · 1 needs your input · 1 ready to merge</div>
</div>
<div class="summary">
<div class="sm work"><div class="n">1</div><div class="l">Working</div></div>
<div class="sm attn"><div class="n">2</div><div class="l">Need you</div></div>
<div class="sm-div"></div>
<div class="sm"><div class="n">6</div><div class="l">Total</div></div>
</div>
</div>
<div class="group-label">Needs attention</div>
<div class="cols">
<div class="card">
<div class="c-top">
<div class="c-id"><div class="br">feat/int-6-api-key-auth</div><div class="mt">int-6 · Sonnet 4.6 · 2m ago</div></div>
<span class="stat input"><span class="d"></span>Needs input</span>
</div>
<div class="c-line">Awaiting your approval to run the database migration on staging before continuing.</div>
<div class="c-foot"><span class="x"><b>PR #2044</b></span><span class="x">3 files</span><span class="sep"></span><span>$0.88</span></div>
</div>
<div class="card">
<div class="c-top">
<div class="c-id"><div class="br">security/cortex-sanitize</div><div class="mt">int-8 · Opus 4.7 · 6m ago</div></div>
<span class="stat fail"><span class="d"></span>CI failed</span>
</div>
<div class="c-line">typecheck failed — 2 errors in plugin-registry.ts. Agent is investigating the failure.</div>
<div class="c-foot"><span class="x"><b>PR #2049</b></span><span class="x bad">2 checks failed</span><span class="sep"></span><span>$3.10</span></div>
</div>
</div>
<div class="group-label">Active</div>
<div class="cols">
<div class="card">
<div class="c-top">
<div class="c-id"><div class="br">session/ao-204</div><div class="mt">ao-204 · Opus 4.7 · now</div></div>
<span class="stat working"><span class="d"></span>Working</span>
</div>
<div class="c-line">Building the refined design direction for the dashboard fleet board…</div>
<div class="c-foot"><span class="x"><b>PR #2051</b></span><span class="x">CI running</span><span class="sep"></span><span>$2.41</span></div>
</div>
<div class="card">
<div class="c-top">
<div class="c-id"><div class="br">docs/architecture-doc</div><div class="mt">ao-201 · Sonnet 4.6 · 4m ago</div></div>
<span class="stat"><span class="d"></span>In review</span>
</div>
<div class="c-line">2 review comments from @prateek — addressing nits in CROSS_PLATFORM.md.</div>
<div class="c-foot"><span class="x"><b>PR #2038</b></span><span class="x ok">✓ CI passed</span><span class="sep"></span><span>$1.20</span></div>
</div>
</div>
<div class="group-label">Settled</div>
<div class="cols">
<div class="card">
<div class="c-top">
<div class="c-id"><div class="br">test/onboarding-harness</div><div class="mt">ao-203 · Sonnet 4.6 · 11m ago</div></div>
<span class="stat"><span class="d"></span>Mergeable</span>
</div>
<div class="c-line">All checks green, approved by 1 reviewer. Ready to merge.</div>
<div class="c-foot"><span class="x"><b>PR #2042</b></span><span class="x ok">✓ approved</span><span class="sep"></span><span>$0.64</span></div>
</div>
<div class="card">
<div class="c-top">
<div class="c-id"><div class="br">docs/architecture-doc-claude</div><div class="mt">ao-202 · Opus 4.7 · 18m ago</div></div>
<span class="stat"><span class="d"></span>Idle</span>
</div>
<div class="c-line">No activity for 18m. Last action: pushed commit 2f9717f.</div>
<div class="c-foot"><span class="x"><b>PR #2040</b></span><span class="x ok">✓ CI passed</span><span class="sep"></span><span>$1.95</span></div>
</div>
</div>
</main>
</div>
</body>
</html>

View File

@ -0,0 +1,428 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AO — Agent Session</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<style>
:root{
--bg:#0a0b0d; --bg-side:#08090b; --bg-top:#0a0b0d;
--panel:#0e0f12; --card:#15171b; --card-hover:#191b20;
--term:#0c0d10;
--ui:"Schibsted Grotesk",sans-serif; --mono:"JetBrains Mono",monospace;
--t1:#f4f5f7; --t2:#9ba1aa; --t3:#646a73; --t4:#444951;
--line:rgba(255,255,255,0.06); --line-2:rgba(255,255,255,0.10);
--blue:#4d8dff; --blue-soft:#7eaaff;
--orange:#f59f4c; --amber:#e8c14a; --red:#ef6b6b; --green:#74b98a;
--hover:rgba(255,255,255,0.04); --hover-2:rgba(255,255,255,0.07);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--t1);font-family:var(--ui);-webkit-font-smoothing:antialiased;overflow:hidden;font-size:14px}
button{font:inherit;cursor:pointer;border:none;background:none;color:inherit}
.app{height:100%;display:grid;grid-template-columns:288px 1fr;grid-template-rows:56px 1fr;
grid-template-areas:"side top" "side main"}
/* ---- sidebar (identical language to the board) ---- */
.sidebar{grid-area:side;background:var(--bg-side);border-right:1px solid var(--line);overflow-y:auto;padding:0 12px 16px;display:flex;flex-direction:column}
.brand{height:56px;flex-shrink:0;display:flex;align-items:center;gap:10px;padding:0 6px;margin-bottom:12px}
.collapse{margin-left:auto;width:28px;height:28px;border-radius:6px;color:var(--t3);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}
.collapse:hover{background:var(--hover);color:var(--t1)}
.mark{width:26px;height:26px;border-radius:7px;background:#000;overflow:hidden;display:grid;place-items:center;box-shadow:inset 0 0 0 1px var(--line),0 2px 10px rgba(77,141,255,.18)}
.mark img{width:23px;height:23px;object-fit:contain;image-rendering:pixelated}
.brand .name{font-weight:700;font-size:14px;letter-spacing:-.015em}
.brand .name b{font-weight:400;color:var(--t3)}
.nav-label{font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.09em;color:var(--t4);padding:0 9px 12px;display:flex;justify-content:space-between}
.nav-label button{color:var(--t4);font-size:13px;display:inline-flex;align-items:center}
.proj{margin-bottom:3px}
.prow{display:flex;align-items:center;gap:10px;padding:8px 9px;border-radius:6px;cursor:pointer}
.prow:hover{background:var(--hover)} .prow.sel{background:var(--hover-2)}
.prow .chev{color:var(--t4);width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}
.prow:hover .chev{color:var(--t2)}
.prow .pn{font-size:13px;font-weight:500;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--t2)}
.prow.sel .pn{color:var(--t1);font-weight:600}
.prow .ct{font-size:11px;color:var(--t3);font-family:var(--mono)}
.slist{padding:4px 0 10px 6px;margin-left:9px;border-left:1px solid var(--line);display:flex;flex-direction:column;gap:2px}
.ses{display:flex;align-items:center;gap:10px;padding:7px 9px 7px 11px;border-radius:6px;cursor:pointer}
.ses:hover{background:var(--hover)}
.ses.sel{background:var(--hover-2)}
.ses .dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:var(--t4)}
.ses .sn{font-size:12px;color:var(--t2);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:var(--mono)}
.ses.sel .sn{color:var(--t1)}
.ses .si{font-size:10px;color:var(--t4);font-family:var(--mono)}
.ses.act .dot{background:var(--orange);animation:breathe 2.4s ease-in-out infinite}
.side-foot{margin-top:auto;padding-top:12px;border-top:1px solid var(--line)}
.side-foot button{display:flex;align-items:center;gap:10px;width:100%;padding:8px;border-radius:6px;color:var(--t3);font-size:13px;font-weight:500}
.side-foot button:hover{background:var(--hover);color:var(--t1)}
/* ---- topbar (session context) ---- */
.topbar{grid-area:top;background:var(--bg-top);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:13px;padding:0 16px 0 14px}
.board-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 12px 7px 9px;border-radius:7px;font-size:14px;font-weight:600;color:var(--t2);flex-shrink:0}
.board-btn:hover{background:var(--hover);color:var(--t1)}
.vdiv{width:1px;height:22px;background:var(--line);margin:0 3px;flex-shrink:0}
.sess-id{display:flex;flex-direction:row;align-items:center;min-width:0;gap:11px}
.sess-id .t{font-size:13.5px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:360px;flex-shrink:1}
.sess-id .b{font-family:var(--mono);font-size:10.5px;color:var(--t3);display:flex;align-items:center;gap:5px;flex-shrink:0;white-space:nowrap}
.sess-id .b .g{color:var(--t4);display:inline-flex;align-items:center}
.stat{display:inline-flex;align-items:center;gap:7px;font-size:11.5px;font-weight:600;color:var(--orange);padding:5px 11px;border-radius:7px;box-shadow:inset 0 0 0 1px rgba(245,159,76,.25);background:rgba(245,159,76,.07);white-space:nowrap}
.stat .d{width:6px;height:6px;border-radius:50%;background:var(--orange);animation:breathe 2.4s ease-in-out infinite}
.spacer{flex:1}
.ibtn{position:relative;width:34px;height:34px;border-radius:7px;display:inline-flex;align-items:center;justify-content:center;color:var(--t2)}
.ibtn:hover{background:var(--hover);color:var(--t1)}
.ibtn .bdg{position:absolute;top:3px;right:3px;min-width:15px;height:15px;padding:0 4px;border-radius:8px;background:var(--amber);color:#1a1400;font-size:9px;font-weight:800;display:grid;place-items:center;box-shadow:0 0 0 2px var(--bg-top)}
.btn{padding:8px 14px;border-radius:7px;font-size:12.5px;font-weight:600;display:inline-flex;align-items:center;gap:7px}
.btn-danger{color:#ff8a8a;box-shadow:inset 0 0 0 1px rgba(239,107,107,.28)} .btn-danger:hover{background:rgba(239,107,107,.1)}
.btn-primary{background:var(--blue);color:#fff} .btn-primary:hover{background:#5b97ff}
/* ---- main split: work surface + inspector ---- */
.main{grid-area:main;display:grid;grid-template-columns:1fr 344px;min-height:0;overflow:hidden}
.work{display:flex;flex-direction:column;min-width:0;min-height:0}
/* framed terminal panel — flush to sidebar + topbar */
.terminal{flex:1;display:flex;flex-direction:column;min-height:0;background:var(--term);overflow:hidden}
.term-head{height:47px;flex-shrink:0;display:flex;align-items:center;gap:10px;padding:0 15px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.012)}
.term-head .lbl{font-size:10.5px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--t3)}
.term-head .sid{font-family:var(--mono);font-size:11px;color:var(--t2)}
.term-head .conn{display:inline-flex;align-items:center;gap:6px;font-family:var(--mono);font-size:10px;color:var(--green);letter-spacing:.04em}
.term-head .conn .d{width:6px;height:6px;border-radius:50%;background:var(--green);box-shadow:0 0 7px var(--green)}
.term-head .tspc{flex:1}
.term-head .zoom{display:inline-flex;align-items:center;gap:8px;color:var(--t3);font-family:var(--mono);font-size:11px}
.term-head .zoom button{width:22px;height:22px;border-radius:5px;display:grid;place-items:center;color:var(--t3)}
.term-head .zoom button:hover{background:var(--hover-2);color:var(--t1)}
.term-body{flex:1;overflow-y:auto;padding:16px 18px;font-family:var(--mono);font-size:12.5px;line-height:1.65;color:#c5ccd3}
.term-body::-webkit-scrollbar{width:9px} .term-body::-webkit-scrollbar-thumb{background:var(--hover-2);border-radius:9px}
.tl{white-space:pre-wrap;word-break:break-word}
.dim{color:var(--t3)} .faint{color:var(--t4)}
.ag{color:var(--orange)} .blu{color:var(--blue-soft)} .grn{color:var(--green)} .amb{color:var(--amber)}
.usr{color:#cdd3da;background:rgba(255,255,255,.03);display:block;padding:7px 11px;border-radius:6px;border-left:2px solid var(--blue);margin:7px 0}
.bullet{color:var(--orange)}
.tool{color:var(--t2)} .tool .tn{color:var(--blue-soft)}
.ok{color:var(--green)}
.caret{display:inline-block;width:7px;height:15px;background:var(--orange);animation:blink 1.1s step-end infinite;vertical-align:-2px}
@keyframes blink{50%{opacity:0}}
/* ---- inspector rail: pluggable view slot ---- */
.insp{background:var(--bg);border-left:1px solid var(--line);display:flex;flex-direction:column;min-height:0;overflow:hidden}
.rail-tabs{height:47px;display:flex;align-items:center;gap:3px;padding:0 11px;border-bottom:1px solid var(--line);flex-shrink:0}
.rt{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:7px 6px;border-radius:7px;font-size:11.5px;font-weight:600;color:var(--t3);min-width:0}
.rt svg{flex-shrink:0}
.rt:hover{background:var(--hover);color:var(--t1)}
.rt.on{background:var(--hover-2);color:var(--t1)}
.rt .lbl{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rail-body{flex:1;overflow-y:auto;padding:18px 18px 40px}
.rail-body::-webkit-scrollbar{width:8px} .rail-body::-webkit-scrollbar-thumb{background:var(--hover-2);border-radius:8px}
.view{display:none} .view.on{display:block}
.sec{margin-bottom:22px}
.sec-h{font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.09em;color:var(--t4);margin-bottom:11px;display:flex;align-items:center;justify-content:space-between}
.sec-h a{color:var(--blue-soft);font-size:11px;font-weight:500;text-transform:none;letter-spacing:0}
.sec-h a:hover{text-decoration:underline;cursor:pointer}
/* overview kv */
.kv{display:flex;flex-direction:column;gap:1px}
.kv .row{display:flex;align-items:center;gap:10px;padding:6px 2px;font-size:12.5px}
.kv .k{color:var(--t3);width:74px;flex-shrink:0}
.kv .v{color:var(--t1);font-family:var(--mono);font-size:11.5px;display:flex;align-items:center;gap:7px;min-width:0}
.kv .v .agdot{width:16px;height:16px;border-radius:4px;background:#000;overflow:hidden;display:inline-grid;place-items:center;box-shadow:inset 0 0 0 1px var(--line)}
.kv .v .agdot img{width:13px;height:13px;image-rendering:pixelated}
.kv .v.path{color:var(--t2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* PR card */
.pr{background:var(--card);border-radius:9px;box-shadow:inset 0 0 0 1px var(--line);padding:13px 14px}
.pr .pr-top{display:flex;align-items:center;gap:8px;margin-bottom:9px}
.pr .num{font-family:var(--mono);font-size:11px;color:var(--t3)}
.pr .pr-state{margin-left:auto;font-size:10px;font-weight:600;color:var(--green);background:rgba(116,185,138,.13);padding:3px 8px;border-radius:5px;text-transform:uppercase;letter-spacing:.04em}
.pr .pr-title{font-size:13px;font-weight:500;line-height:1.4;color:var(--t1);margin-bottom:11px}
.pr .pr-branch{font-family:var(--mono);font-size:11px;color:var(--t2);display:flex;align-items:center;gap:6px;margin-bottom:13px}
.pr .pr-branch .g{color:var(--t4);display:inline-flex} .pr .pr-branch .arr{color:var(--t4)}
.checks{display:flex;flex-direction:column;gap:7px;border-top:1px solid var(--line);padding-top:12px;margin-bottom:13px}
.chk{display:flex;align-items:center;gap:9px;font-size:11.5px;color:var(--t2)}
.chk .ic{width:14px;height:14px;flex-shrink:0;display:inline-flex}
.chk .nm{flex:1;font-family:var(--mono);font-size:11px}
.chk .ms{color:var(--t4);font-family:var(--mono);font-size:10px}
.chk.pass .ic{color:var(--green)} .chk.run .ic{color:var(--amber)} .chk.fail .ic{color:var(--red)}
.pr-acts{display:flex;gap:8px}
.pr-acts button{flex:1;padding:8px;border-radius:7px;font-size:12px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;gap:6px}
.pr-acts .view{color:var(--t1);box-shadow:inset 0 0 0 1px var(--line-2)} .pr-acts .view:hover{background:var(--hover)}
.pr-acts .merge{background:var(--green);color:#06210f} .pr-acts .merge:hover{filter:brightness(1.07)}
/* review comments — quiet by default */
.rev{display:flex;flex-direction:column;gap:7px}
.rev-item{background:var(--card);border-radius:8px;box-shadow:inset 0 0 0 1px var(--line);padding:11px 13px}
.rev-item.resolved{opacity:.55}
.rev-top{display:flex;align-items:center;gap:7px;margin-bottom:6px}
.rev-av{width:19px;height:19px;border-radius:50%;background:var(--hover-2);color:var(--t2);font-size:8.5px;font-weight:700;display:grid;place-items:center;flex-shrink:0}
.rev-who{font-size:12px;font-weight:600;color:var(--t1);display:inline-flex;align-items:center;gap:6px}
.rev-who .od{width:5px;height:5px;border-radius:50%;background:var(--amber);flex-shrink:0}
.rev-loc{font-family:var(--mono);font-size:10px;color:var(--t4);margin-left:auto;cursor:pointer;white-space:nowrap}
.rev-loc:hover{color:var(--blue-soft)}
.rev-body{font-size:12px;color:var(--t2);line-height:1.5}
.rev-body code{font-family:var(--mono);font-size:10.5px;color:var(--t1);background:var(--hover-2);padding:1px 5px;border-radius:4px}
.rev-acts{display:flex;align-items:center;gap:9px;margin-top:10px}
.rev-acts .addr{padding:5px 10px;border-radius:6px;background:rgba(77,141,255,.1);color:var(--blue-soft);font-size:11px;font-weight:600;display:inline-flex;align-items:center;gap:6px}
.rev-acts .addr:hover{background:rgba(77,141,255,.18);color:#9cbcff}
.rev-acts .lnk{font-size:11px;font-weight:600;color:var(--t3)}
.rev-acts .lnk:hover{color:var(--t1)}
.rev-acts .sp{flex:1}
/* activity timeline */
.tline{position:relative;padding-left:20px}
.tline::before{content:"";position:absolute;left:5px;top:4px;bottom:6px;width:1px;background:var(--line)}
.ev{position:relative;padding:0 0 15px}
.ev:last-child{padding-bottom:0}
.ev .node{position:absolute;left:-18px;top:3px;width:9px;height:9px;border-radius:50%;background:var(--t4);box-shadow:0 0 0 3px var(--bg)}
.ev.now .node{background:var(--orange);box-shadow:0 0 0 3px var(--bg),0 0 8px var(--orange)}
.ev.good .node{background:var(--green)} .ev.warn .node{background:var(--amber)}
.ev .et{font-size:12px;color:var(--t1);line-height:1.45}
.ev .et b{font-weight:600}
.ev .ets{font-size:10.5px;color:var(--t4);font-family:var(--mono);margin-top:2px}
/* ---- Changes / diff view ---- */
.diff-sum{font-family:var(--mono);font-size:11px;color:var(--t3);margin-bottom:13px;display:flex;gap:9px}
.diff-sum .a{color:var(--green)} .diff-sum .d{color:var(--red)}
.files{display:flex;flex-direction:column;gap:1px;margin-bottom:20px}
.file{display:flex;align-items:center;gap:9px;padding:7px 9px;border-radius:6px;cursor:pointer}
.file:hover{background:var(--hover)} .file.sel{background:var(--hover-2)}
.file .fi{color:var(--t4);display:inline-flex;flex-shrink:0}
.file .fp{flex:1;font-family:var(--mono);font-size:11.5px;color:var(--t2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file.sel .fp{color:var(--t1)}
.file .fs{font-family:var(--mono);font-size:10px;display:flex;gap:6px;flex-shrink:0}
.file .fs .a{color:var(--green)} .file .fs .d{color:var(--red)}
.hunk{background:var(--term);border-radius:8px;box-shadow:inset 0 0 0 1px var(--line);overflow:hidden;font-family:var(--mono);font-size:11px;line-height:1.7}
.hunk .hh{padding:7px 12px;border-bottom:1px solid var(--line);color:var(--t3);font-size:10.5px;display:flex;align-items:center;gap:8px}
.hunk .hh .fp{color:var(--t2)}
.dl{display:flex;padding:0 12px;white-space:pre}
.dl .ln{width:30px;color:var(--t4);flex-shrink:0;text-align:right;padding-right:12px;user-select:none}
.dl .tx{color:#aeb6bf}
.dl.add{background:rgba(116,185,138,.09)} .dl.add .tx{color:#9fd6b0} .dl.add .ln{color:rgba(116,185,138,.5)}
.dl.del{background:rgba(239,107,107,.08)} .dl.del .tx{color:#e9a2a2} .dl.del .ln{color:rgba(239,107,107,.5)}
.dl .mk{width:14px;flex-shrink:0;color:var(--t4)}
.dl.add .mk{color:var(--green)} .dl.del .mk{color:var(--red)}
/* ---- Agent browser view ---- */
.brz{background:var(--card);border-radius:9px;box-shadow:inset 0 0 0 1px var(--line);overflow:hidden;margin-bottom:14px}
.brz-bar{display:flex;align-items:center;gap:7px;padding:9px 10px;border-bottom:1px solid var(--line)}
.brz-bar .nav{display:flex;gap:3px}
.brz-bar .nav button{width:22px;height:22px;border-radius:5px;color:var(--t3);display:grid;place-items:center}
.brz-bar .nav button:hover{background:var(--hover-2);color:var(--t1)}
.brz-url{flex:1;display:flex;align-items:center;gap:7px;background:var(--term);box-shadow:inset 0 0 0 1px var(--line);border-radius:6px;padding:5px 9px;font-family:var(--mono);font-size:10.5px;color:var(--t2);min-width:0}
.brz-url .lk{color:var(--green);flex-shrink:0;display:inline-flex} .brz-url .u{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.brz-frame{height:208px;background:#000;overflow:hidden;position:relative}
.brz-frame iframe{width:1280px;height:860px;border:0;transform:scale(.241);transform-origin:top left}
.brz-cap{font-size:11px;color:var(--t3);line-height:1.5}
.brz-cap b{color:var(--t2);font-weight:600}
.live-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green);box-shadow:0 0 6px var(--green);margin-right:5px;vertical-align:1px}
@keyframes breathe{0%,100%{opacity:1}50%{opacity:.35}}
</style>
</head>
<body>
<div class="app">
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="brand"><span class="mark"><img src="mascot.png" alt="AO" /></span><span class="name">Agent<b> / </b>Orchestrator</span>
<button class="collapse" title="Collapse sidebar"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2.5"/><line x1="9" y1="4" x2="9" y2="20"/><path d="m15.5 9-3 3 3 3"/></svg></button>
</div>
<div class="nav-label">Projects <button><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg></button></div>
<div class="proj"><div class="prow"><span class="chev"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg></span><span class="pn">integrator</span><span class="ct">5</span></div></div>
<div class="proj">
<div class="prow sel"><span class="chev"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span><span class="pn">Agent Orchestrator</span><span class="ct">4</span></div>
<div class="slist">
<div class="ses act sel"><span class="dot"></span><span class="sn">session/ao-2-1</span><span class="si">ao-2-1</span></div>
<div class="ses"><span class="dot"></span><span class="sn">docs/architecture-doc</span><span class="si">ao-201</span></div>
<div class="ses"><span class="dot"></span><span class="sn">docs/architecture-doc-claude</span><span class="si">ao-202</span></div>
<div class="ses"><span class="dot"></span><span class="sn">test/onboarding-harness</span><span class="si">ao-203</span></div>
</div>
</div>
<div class="proj"><div class="prow"><span class="chev"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg></span><span class="pn">mercury</span><span class="ct">2</span></div></div>
<div class="proj"><div class="prow"><span class="chev"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg></span><span class="pn">WorkOS</span><span class="ct">0</span></div></div>
<div class="side-foot">
<button><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</button>
</div>
</aside>
<!-- TOPBAR -->
<header class="topbar">
<button class="board-btn" title="Back to Kanban (⌘[)"><svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>Kanban</button>
<span class="vdiv"></span>
<div class="sess-id">
<span class="t">Design the agent session detail page</span>
<span class="b"><span class="g"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="4" x2="6" y2="14"/><circle cx="6" cy="17" r="2.3"/><circle cx="18" cy="7" r="2.3"/><path d="M18 9.3a8 8 0 0 1-8 8"/></svg></span>session/ao-2-1</span>
</div>
<span class="stat"><span class="d"></span>Working</span>
<div class="spacer"></div>
<button class="ibtn" title="Notifications"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg><span class="bdg">2</span></button>
<button class="btn btn-danger"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/><path d="M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13"/><line x1="10" y1="11.5" x2="10" y2="17.5"/><line x1="14" y1="11.5" x2="14" y2="17.5"/></svg>Kill</button>
<button class="btn btn-primary"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2.5" width="6" height="4.5" rx="1.4"/><rect x="2.5" y="17" width="6" height="4.5" rx="1.4"/><rect x="9" y="17" width="6" height="4.5" rx="1.4"/><rect x="15.5" y="17" width="6" height="4.5" rx="1.4"/><path d="M12 7v4.5M5.5 17v-5.5h13V17M12 11.5V17"/></svg>Orchestrator</button>
</header>
<!-- MAIN: work surface + inspector -->
<div class="main">
<!-- WORK -->
<section class="work">
<div class="terminal">
<div class="term-head">
<span class="lbl">Terminal</span><span class="sid">ao-2-1</span>
<span class="tspc"></span>
<span class="zoom"><button></button>13px<button>+</button></span>
<button class="zoom" style="margin-left:6px"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg></button>
</div>
<!-- ════════════════════════════════════════════════════════════
LIVE xterm.js VIEWPORT — real tmux PTY over WebSocket.
We do NOT style this content; Claude Code emits its own ANSI.
We only set the xterm.js `theme` + font (see palette below).
The text here is a faithful stand-in for what the PTY renders.
════════════════════════════════════════════════════════════ -->
<div class="term-body" id="xterm-viewport">
<div class="tl"><span class="ag">✦ Claude Code</span> <span class="dim">v2.1.150 · Opus 4.7 · ~/…/worktrees/ao-2-1</span></div>
<div class="tl">&nbsp;</div>
<div class="tl"><span class="bullet"></span> Reusing the kanban token set — surfaces, status palette, mascot mark, SVG icons.</div>
<div class="tl">&nbsp;&nbsp;<span class="tool">Write(<span class="tn">design-session.html</span>)</span> <span class="faint">⎿ wrote 182 lines</span></div>
<div class="tl">&nbsp;&nbsp;<span class="tool">Bash(<span class="tn">open design-session.html</span>)</span></div>
<div class="tl">&nbsp;&nbsp;&nbsp;&nbsp;<span class="ok"></span> <span class="dim">launched preview in browser</span></div>
<div class="tl">&nbsp;</div>
<div class="tl"><span class="bullet"></span> Wired overview, pull-request status, and an activity timeline into the rail.</div>
<div class="tl">&nbsp;&nbsp;Color stays rationed — neutral by default. <span class="dim">Working on the inspector…</span></div>
<div class="tl">&nbsp;</div>
<div class="tl faint">───────────────────────────────────────────────────────────────</div>
<div class="tl"><span class="blu"></span> <span class="caret"></span></div>
<div class="tl">&nbsp;</div>
<div class="tl faint"> ▸▸ bypass permissions on (shift+tab to cycle) · ⇥ for agents</div>
</div>
</div>
</section>
<!-- INSPECTOR — pluggable view slot -->
<aside class="insp">
<div class="rail-tabs">
<button class="rt on" data-v="summary"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="7" x2="20" y2="7"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="17" x2="16" y2="17"/><circle cx="4" cy="7" r="1"/><circle cx="4" cy="12" r="1"/><circle cx="4" cy="17" r="1"/></svg><span class="lbl">Summary</span></button>
<button class="rt" data-v="diff"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v6"/><path d="M9 6h6"/><path d="M11 18H7a2 2 0 0 1-2-2V6"/><path d="M13 15h4"/><circle cx="5" cy="4" r="0"/><path d="M19 9v7a2 2 0 0 1-2 2h-2"/></svg><span class="lbl">Changes</span></button>
<button class="rt" data-v="browser"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="3" y1="12" x2="21" y2="12"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18"/></svg><span class="lbl">Browser</span></button>
</div>
<div class="rail-body">
<!-- SUMMARY -->
<div class="view on" id="v-summary">
<div class="sec">
<div class="sec-h">Pull request <a>Open ↗</a></div>
<div class="pr">
<div class="pr-top"><span class="num">#2051</span><span class="pr-state">Open</span></div>
<div class="pr-title">Add agent session detail page to the dashboard</div>
<div class="pr-branch"><span class="g"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="4" x2="6" y2="14"/><circle cx="6" cy="17" r="2.3"/><circle cx="18" cy="7" r="2.3"/><path d="M18 9.3a8 8 0 0 1-8 8"/></svg></span>session/ao-2-1 <span class="arr"></span> main</div>
<div class="checks">
<div class="chk pass"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span><span class="nm">lint</span><span class="ms">12s</span></div>
<div class="chk pass"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span><span class="nm">typecheck</span><span class="ms">34s</span></div>
<div class="chk run"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 1 0 9 9"/></svg></span><span class="nm">test</span><span class="ms">running…</span></div>
<div class="chk pass"><span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></span><span class="nm">build</span><span class="ms">1m 02s</span></div>
</div>
<div class="pr-acts">
<button class="view">View diff</button>
<button class="merge">Merge when green</button>
</div>
</div>
</div>
<div class="sec">
<div class="sec-h">Review comments <a>2 unresolved</a></div>
<div class="rev">
<div class="rev-item">
<div class="rev-top"><span class="rev-av">PK</span><span class="rev-who"><span class="od"></span>prateek</span><span class="rev-loc" title="Jump to line">design-session.html:240</span></div>
<div class="rev-body">Use the shared <code>StatusBadge</code> here instead of a local span — keeps the status palette in one place.</div>
<div class="rev-acts"><button class="addr" title="Send this comment to the agent to fix"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 3 12.7 7.3 17 9 12.7 10.7 11 15 9.3 10.7 5 9 9.3 7.3z"/><path d="M18 14l.7 1.9 1.9.7-1.9.7L18 19.2l-.7-1.9-1.9-.7 1.9-.7z"/></svg>Address</button></div>
</div>
<div class="rev-item">
<div class="rev-top"><span class="rev-av">JL</span><span class="rev-who"><span class="od"></span>jordan</span><span class="rev-loc" title="Jump to line">design-session.html:188</span></div>
<div class="rev-body">Inline <code>style=</code> on the zoom button trips the lint rule (C-02). Move it to a class.</div>
<div class="rev-acts"><button class="addr" title="Send this comment to the agent to fix"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 3 12.7 7.3 17 9 12.7 10.7 11 15 9.3 10.7 5 9 9.3 7.3z"/><path d="M18 14l.7 1.9 1.9.7-1.9.7L18 19.2l-.7-1.9-1.9-.7 1.9-.7z"/></svg>Address</button></div>
</div>
<div class="rev-item resolved">
<div class="rev-top"><span class="rev-av">PK</span><span class="rev-who">prateek</span><span class="rev-loc">resolved</span></div>
<div class="rev-body">Nice — the org-chart icon reads way better than the share glyph.</div>
</div>
</div>
</div>
<div class="sec">
<div class="sec-h">Activity</div>
<div class="tline">
<div class="ev now"><span class="node"></span><div class="et"><b>Working</b> — writing design-session.html</div><div class="ets">now</div></div>
<div class="ev good"><span class="node"></span><div class="et">Opened <b>PR #2051</b> against main</div><div class="ets">8m ago</div></div>
<div class="ev"><span class="node"></span><div class="et">Pushed commit <b>a3f9c2e</b> · 3 files</div><div class="ets">9m ago</div></div>
<div class="ev warn"><span class="node"></span><div class="et">Waited on input → resolved</div><div class="ets">26m ago</div></div>
<div class="ev"><span class="node"></span><div class="et">Created worktree &amp; branch</div><div class="ets">2h 14m ago</div></div>
</div>
</div>
<div class="sec">
<div class="sec-h">Overview</div>
<div class="kv">
<div class="row"><span class="k">Worktree</span><span class="v path">~/…/worktrees/ao-2-1</span></div>
<div class="row"><span class="k">Started</span><span class="v">2h 14m ago</span></div>
<div class="row"><span class="k">Tokens</span><span class="v">418k <span class="faint">in</span> · 52k <span class="faint">out</span></span></div>
<div class="row"><span class="k">Cost</span><span class="v">$2.41</span></div>
</div>
</div>
</div><!-- /summary -->
<!-- CHANGES / DIFF -->
<div class="view" id="v-diff">
<div class="sec-h">Working tree <span style="text-transform:none;letter-spacing:0;color:var(--t3)">vs main</span></div>
<div class="diff-sum"><span>4 files</span><span class="a">+418</span><span class="d">24</span></div>
<div class="files">
<div class="file sel"><span class="fi"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg></span><span class="fp">design-session.html</span><span class="fs"><span class="a">+182</span><span class="d">24</span></span></div>
<div class="file"><span class="fi"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg></span><span class="fp">design-orgchart-icons.html</span><span class="fs"><span class="a">+96</span></span></div>
<div class="file"><span class="fi"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg></span><span class="fp">design-orchestrator-icons.html</span><span class="fs"><span class="a">+140</span></span></div>
<div class="file"><span class="fi"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.6"/><path d="m21 15-5-5L5 21"/></svg></span><span class="fp">mascot.png</span><span class="fs"><span class="a">bin</span></span></div>
</div>
<div class="hunk">
<div class="hh"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg><span class="fp">design-session.html</span> · topbar</div>
<div class="dl"><span class="ln">241</span><span class="mk"></span><span class="tx"> &lt;div class="spacer"&gt;&lt;/div&gt;</span></div>
<div class="dl del"><span class="ln">242</span><span class="mk"></span><span class="tx"> &lt;button class="btn btn-danger"&gt;…power…Kill&lt;/button&gt;</span></div>
<div class="dl add"><span class="ln">242</span><span class="mk">+</span><span class="tx"> &lt;button class="btn btn-danger"&gt;…trash…Kill&lt;/button&gt;</span></div>
<div class="dl add"><span class="ln">243</span><span class="mk">+</span><span class="tx"> &lt;button class="btn btn-primary"&gt;…orgchart…</span></div>
<div class="dl add"><span class="ln">244</span><span class="mk">+</span><span class="tx"> Orchestrator&lt;/button&gt;</span></div>
<div class="dl"><span class="ln">245</span><span class="mk"></span><span class="tx"> &lt;/header&gt;</span></div>
</div>
</div><!-- /diff -->
<!-- AGENT BROWSER -->
<div class="view" id="v-browser">
<div class="sec-h">Agent browser <span style="text-transform:none;letter-spacing:0;color:var(--t3)">live preview</span></div>
<div class="brz">
<div class="brz-bar">
<div class="nav">
<button title="Back"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg></button>
<button title="Reload"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></button>
</div>
<div class="brz-url"><span class="lk"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></span><span class="u">localhost:3001/projects/ao/board</span></div>
</div>
<div class="brz-frame"><iframe src="kanban.html" title="agent browser preview" scrolling="no"></iframe></div>
</div>
<div class="brz-cap"><span class="live-dot"></span><b>Agent is viewing this page.</b> A browser plugin (web preview / Playwright-driven session) renders what the agent navigates — live, scrollable, clickable.</div>
</div><!-- /browser -->
</div><!-- /rail-body -->
</aside>
</div>
</div>
<script>
document.querySelectorAll('.rt').forEach(b=>{
b.addEventListener('click',()=>{
document.querySelectorAll('.rt').forEach(x=>x.classList.remove('on'));
document.querySelectorAll('.view').forEach(x=>x.classList.remove('on'));
b.classList.add('on');
document.getElementById('v-'+b.dataset.v).classList.add('on');
});
});
</script>
</body>
</html>

View File

@ -20,6 +20,7 @@ import { tmpdir } from "node:os";
import { parse as parseYaml } from "yaml";
import { EventEmitter } from "node:events";
import {
generateExternalId,
getDefaultRuntime,
recordActivityEvent,
type SessionManager,
@ -2828,6 +2829,82 @@ describe("start command — already-running detection", () => {
expect(newKey).toMatch(/^my-app-/);
});
it("creates new orchestrator entry in the global registry when cwd config is flat", async () => {
mockIsAlreadyRunning.mockResolvedValue({
pid: 9999,
configPath: "/fake/config.yaml",
port: 3000,
startedAt: "2026-01-01T00:00:00Z",
projects: ["agent-orchestrator_5dce9e3fe8"],
});
mockPromptSelect.mockResolvedValue("new");
const repoDir = join(tmpDir, "agent-orchestrator");
createFakeRepo(repoDir, "https://github.com/org/agent-orchestrator.git");
const localConfigPath = join(repoDir, "agent-orchestrator.yaml");
writeFileSync(localConfigPath, "agent: claude-code\n");
const projectId = generateExternalId(
repoDir,
"https://github.com/org/agent-orchestrator.git",
);
const globalConfigPath = process.env["AO_GLOBAL_CONFIG"]!;
const { stringify: yamlStringify } = await import("yaml");
writeFileSync(
globalConfigPath,
yamlStringify(
{
defaults: {
runtime: "process",
agent: "claude-code",
workspace: "worktree",
notifiers: [],
},
projects: {
[projectId]: {
projectId,
path: repoDir,
defaultBranch: "main",
displayName: "Agent Orchestrator",
sessionPrefix: "app",
},
},
},
{ indent: 2 },
),
);
mockConfigRef.current = makeConfig({
[projectId]: makeProject({
name: "Agent Orchestrator",
path: repoDir,
sessionPrefix: "app",
}),
});
(mockConfigRef.current as Record<string, unknown>).configPath = localConfigPath;
try {
await program.parseAsync(["node", "test", "start", "--no-dashboard", "--no-orchestrator"]);
} catch {
// Startup may throw after the config mutation; this test only covers
// the flat-config new-orchestrator mutation path.
}
const updatedGlobal = parseYaml(readFileSync(globalConfigPath, "utf-8")) as {
projects: Record<string, Record<string, unknown>>;
};
const projectKeys = Object.keys(updatedGlobal.projects);
expect(projectKeys).toHaveLength(2);
expect(projectKeys).toContain(projectId);
const newKey = projectKeys.find((key) => key !== projectId);
expect(newKey).toMatch(new RegExp(`^${projectId}-`));
expect(updatedGlobal.projects[newKey!].path).toBe(repoDir);
const localConfig = readFileSync(localConfigPath, "utf-8");
expect(localConfig).not.toContain("projects:");
});
it("does not mutate YAML when non-TTY caller detects already running (path arg)", async () => {
mockIsAlreadyRunning.mockResolvedValue({
pid: 9999,
@ -3208,4 +3285,60 @@ describe("start command — global registry mutations", () => {
else process.env["AO_GLOBAL_CONFIG"] = origGlobalEnv;
}
});
it("writes interactive agent overrides to a flat repo-local config", async () => {
const repoDir = join(tmpDir, "current");
createFakeRepo(repoDir, "https://github.com/org/current.git");
const localConfigPath = join(repoDir, "agent-orchestrator.yaml");
writeFileSync(localConfigPath, "agent: claude-code\n");
const projectId = generateExternalId(repoDir, "https://github.com/org/current.git");
mockConfigRef.current = makeConfig({
[projectId]: makeProject({
name: "Current",
path: repoDir,
sessionPrefix: "current",
}),
});
(mockConfigRef.current as Record<string, unknown>).configPath = localConfigPath;
const detectAgent = await import("../../src/lib/detect-agent.js");
vi.mocked(detectAgent.detectAvailableAgents).mockResolvedValue([
{ name: "claude-code", displayName: "Claude Code" },
{ name: "codex", displayName: "OpenAI Codex" },
]);
mockPromptSelect.mockResolvedValueOnce("claude-code").mockResolvedValueOnce("codex");
const originalStdinTty = process.stdin.isTTY;
const originalStdoutTty = process.stdout.isTTY;
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
try {
await program.parseAsync([
"node",
"test",
"start",
"--interactive",
"--no-dashboard",
"--no-orchestrator",
]);
const localConfig = readFileSync(localConfigPath, "utf-8");
expect(localConfig).toContain("agent: claude-code");
expect(localConfig).toContain("orchestrator:");
expect(localConfig).toContain("worker:");
expect(localConfig).toContain("agent: codex");
expect(localConfig).not.toContain("projects:");
} finally {
Object.defineProperty(process.stdin, "isTTY", {
value: originalStdinTty,
configurable: true,
});
Object.defineProperty(process.stdout, "isTTY", {
value: originalStdoutTty,
configurable: true,
});
}
});
});

View File

@ -55,6 +55,10 @@ const { mockGlobalConfig } = vi.hoisted(() => ({
mockGlobalConfig: { value: null as null | { updateChannel?: string; installMethod?: string } },
}));
const { mockGetInstalledAoVersion } = vi.hoisted(() => ({
mockGetInstalledAoVersion: vi.fn(() => "0.0.0"),
}));
import type * as AoCoreType from "@aoagents/ao-core";
vi.mock("@aoagents/ao-core", async () => {
@ -62,6 +66,7 @@ vi.mock("@aoagents/ao-core", async () => {
return {
...actual,
loadGlobalConfig: () => mockGlobalConfig.value,
getInstalledAoVersion: () => mockGetInstalledAoVersion(),
};
});
@ -109,6 +114,8 @@ describe("update-check", () => {
// Default to nightly so checkForUpdate exercises the registry path.
// Individual tests reset this when they need different channel behavior.
mockGlobalConfig.value = { updateChannel: "nightly" };
mockGetInstalledAoVersion.mockReturnValue("0.0.0");
mockGetCliVersion.mockReturnValue("0.2.2");
});
afterEach(() => {
@ -334,6 +341,20 @@ describe("update-check", () => {
const version = getCurrentVersion();
expect(version).toMatch(/^\d+\.\d+\.\d+/);
});
it("uses the core-installed AO version when it is available", () => {
mockGetInstalledAoVersion.mockReturnValue("0.9.3");
mockGetCliVersion.mockReturnValue("0.0.0");
expect(getCurrentVersion()).toBe("0.9.3");
});
it("falls back to the CLI package version when core only has the placeholder", () => {
mockGetInstalledAoVersion.mockReturnValue("0.0.0");
mockGetCliVersion.mockReturnValue("0.9.3");
expect(getCurrentVersion()).toBe("0.9.3");
});
});
// -----------------------------------------------------------------------
@ -1036,6 +1057,52 @@ describe("update-check", () => {
expect(output).toContain("npm install -g @aoagents/ao@latest");
});
it("does not print placeholder 0.0.0 when the current version is unknown", () => {
mockGlobalConfig.value = { updateChannel: "nightly" };
mockGetInstalledAoVersion.mockReturnValue("0.0.0");
mockGetCliVersion.mockReturnValue("0.0.0");
mockReadFileSync.mockReturnValue(
JSON.stringify({
latestVersion: "0.9.3-nightly-abc",
checkedAt: new Date().toISOString(),
currentVersionAtCheck: "0.0.0",
installMethod: "unknown",
channel: "nightly",
}),
);
maybeShowUpdateNotice();
expect(stderrSpy).toHaveBeenCalledTimes(1);
const output = stderrSpy.mock.calls[0]![0] as string;
expect(output).toContain("Update available (nightly): update to latest version");
expect(output).toContain("npm install -g @aoagents/ao@nightly");
expect(output).not.toContain("0.0.0");
});
it("does not print placeholder 0.0.0 on the stable channel", () => {
mockGlobalConfig.value = { updateChannel: "stable" };
mockGetInstalledAoVersion.mockReturnValue("0.0.0");
mockGetCliVersion.mockReturnValue("0.0.0");
mockReadFileSync.mockReturnValue(
JSON.stringify({
latestVersion: "0.9.3",
checkedAt: new Date().toISOString(),
currentVersionAtCheck: "0.0.0",
installMethod: "unknown",
channel: "stable",
}),
);
maybeShowUpdateNotice();
expect(stderrSpy).toHaveBeenCalledTimes(1);
const output = stderrSpy.mock.calls[0]![0] as string;
expect(output).toContain("Update available: update to latest version");
expect(output).toContain("npm install -g @aoagents/ao@latest");
expect(output).not.toContain("0.0.0");
});
it("prints git update notice from cached git state", () => {
mockGlobalConfig.value = { updateChannel: "stable" };
const currentVersion = getCurrentVersion();

View File

@ -1630,12 +1630,44 @@ export function registerStart(program: Command): void {
// ── Handle "new orchestrator" choice (deferred from already-running check) ──
if (startNewOrchestrator) {
const rawYaml = readFileSync(config.configPath, "utf-8");
const rawConfig = yamlParse(rawYaml);
let mutationConfigPath = config.configPath;
let rawYaml = readFileSync(mutationConfigPath, "utf-8");
let rawConfig = yamlParse(rawYaml) as Record<string, unknown> | null;
let projects =
rawConfig &&
typeof rawConfig === "object" &&
rawConfig["projects"] &&
typeof rawConfig["projects"] === "object"
? (rawConfig["projects"] as Record<string, Record<string, unknown> | undefined>)
: null;
if (!projects && !isCanonicalGlobalConfigPath(mutationConfigPath)) {
const globalPath = getGlobalConfigPath();
if (existsSync(globalPath)) {
mutationConfigPath = globalPath;
rawYaml = readFileSync(mutationConfigPath, "utf-8");
rawConfig = yamlParse(rawYaml) as Record<string, unknown> | null;
projects =
rawConfig &&
typeof rawConfig === "object" &&
rawConfig["projects"] &&
typeof rawConfig["projects"] === "object"
? (rawConfig["projects"] as Record<
string,
Record<string, unknown> | undefined
>)
: null;
}
}
if (!rawConfig || !projects || !projects[projectId]) {
throw new Error(`Project "${projectId}" not found in a writable project registry.`);
}
// Collect existing prefixes to avoid collisions
const existingPrefixes = new Set(
Object.values(rawConfig.projects as Record<string, Record<string, unknown>>)
Object.values(projects)
.filter((p): p is Record<string, unknown> => p !== undefined)
.map((p) => p.sessionPrefix as string)
.filter(Boolean),
);
@ -1646,18 +1678,18 @@ export function registerStart(program: Command): void {
const suffix = Math.random().toString(36).slice(2, 6);
newId = `${projectId}-${suffix}`;
newPrefix = generateSessionPrefix(newId);
} while (rawConfig.projects[newId] || existingPrefixes.has(newPrefix));
} while (projects[newId] || existingPrefixes.has(newPrefix));
rawConfig.projects[newId] = {
...rawConfig.projects[projectId],
projects[newId] = {
...projects[projectId],
sessionPrefix: newPrefix,
};
const nextYaml = isCanonicalGlobalConfigPath(config.configPath)
const nextYaml = isCanonicalGlobalConfigPath(mutationConfigPath)
? yamlStringify(rawConfig, { indent: 2 })
: configToYaml(rawConfig as Record<string, unknown>);
writeFileSync(config.configPath, nextYaml);
writeFileSync(mutationConfigPath, nextYaml);
console.log(chalk.green(`\n✓ New orchestrator "${newId}" added to config\n`));
config = loadConfig(config.configPath);
config = loadConfig(mutationConfigPath);
projectId = newId;
project = config.projects[newId];
}
@ -1698,6 +1730,7 @@ export function registerStart(program: Command): void {
const agentOverride = opts?.interactive ? await promptAgentSelection() : null;
if (agentOverride) {
const { orchestratorAgent, workerAgent } = agentOverride;
let updatedProject: ProjectConfig | null = null;
if (isCanonicalGlobalConfigPath(config.configPath)) {
const nextLocalConfig = readProjectBehaviorConfig(project.path);
@ -1713,15 +1746,70 @@ export function registerStart(program: Command): void {
console.log(chalk.dim(` ✓ Saved to ${project.path}/agent-orchestrator.yaml\n`));
} else {
const rawYaml = readFileSync(config.configPath, "utf-8");
const rawConfig = yamlParse(rawYaml);
const proj = rawConfig.projects[projectId];
proj.orchestrator = { ...(proj.orchestrator ?? {}), agent: orchestratorAgent };
proj.worker = { ...(proj.worker ?? {}), agent: workerAgent };
writeFileSync(config.configPath, configToYaml(rawConfig as Record<string, unknown>));
const rawConfig = yamlParse(rawYaml) as Record<string, unknown> | null;
const projects =
rawConfig &&
typeof rawConfig === "object" &&
rawConfig["projects"] &&
typeof rawConfig["projects"] === "object"
? (rawConfig["projects"] as Record<string, Record<string, unknown> | undefined>)
: null;
if (projects) {
const proj = projects[projectId];
if (!proj) {
throw new Error(`Project "${projectId}" not found in ${config.configPath}`);
}
proj.orchestrator = {
...((proj.orchestrator as Record<string, unknown> | undefined) ?? {}),
agent: orchestratorAgent,
};
proj.worker = {
...((proj.worker as Record<string, unknown> | undefined) ?? {}),
agent: workerAgent,
};
writeFileSync(
config.configPath,
configToYaml(rawConfig as Record<string, unknown>),
);
} else {
const nextLocalConfig = readProjectBehaviorConfig(project.path);
nextLocalConfig.orchestrator = {
...(nextLocalConfig.orchestrator ?? {}),
agent: orchestratorAgent,
};
nextLocalConfig.worker = {
...(nextLocalConfig.worker ?? {}),
agent: workerAgent,
};
writeProjectBehaviorConfig(project.path, nextLocalConfig);
updatedProject = {
...project,
orchestrator: {
...(project.orchestrator ?? {}),
agent: orchestratorAgent,
},
worker: {
...(project.worker ?? {}),
agent: workerAgent,
},
};
}
console.log(chalk.dim(` ✓ Saved to ${config.configPath}\n`));
}
config = loadConfig(config.configPath);
project = config.projects[projectId];
if (updatedProject) {
project = updatedProject;
config = {
...config,
projects: {
...config.projects,
[projectId]: updatedProject,
},
};
} else {
config = loadConfig(config.configPath);
project = config.projects[projectId];
}
}
const actualPort = await runStartup(config, projectId, project, opts);

View File

@ -719,6 +719,7 @@ async function showFallbackStatus(): Promise<void> {
branch: null,
issueId: null,
pr: null,
prs: [],
workspacePath: null,
runtimeHandle: lifecycle.runtime.handle,
agentInfo: null,

View File

@ -92,6 +92,18 @@ const DEMO_PR_CONTEXT: NotificationEventContext = {
repo: "agent-orchestrator",
isDraft: false,
},
prs: [
{
number: 1579,
url: DEMO_PR_URL,
title: "Normalize AO notifier payloads",
branch: "ao/demo-notifier-harness",
baseBranch: "main",
owner: "ComposioHQ",
repo: "agent-orchestrator",
isDraft: false,
},
],
issueId: "AO-1579",
issueTitle: "Make AO notification payloads API-grade",
summary: "Normalize AO notifier payloads",
@ -100,6 +112,7 @@ const DEMO_PR_CONTEXT: NotificationEventContext = {
const DEMO_SYSTEM_CONTEXT: NotificationEventContext = {
pr: null,
prs: [],
issueId: null,
issueTitle: null,
summary: "AO notification delivery smoke test",

View File

@ -597,10 +597,14 @@ export function maybeShowUpdateNotice(): void {
const channelSuffix = channel === "nightly" ? " (nightly)" : "";
const command = getUpdateCommand(installMethod, channel);
const message =
installMethod === "git"
? `\nUpdate available${channelSuffix} from ${cached.latestVersion} — Run: ${command}\n\n`
: `\nUpdate available${channelSuffix}: ${currentVersion}${cached.latestVersion} — Run: ${command}\n\n`;
let message: string;
if (installMethod === "git") {
message = `\nUpdate available${channelSuffix} from ${cached.latestVersion} — Run: ${command}\n\n`;
} else if (currentVersion === "0.0.0") {
message = `\nUpdate available${channelSuffix}: update to latest version — Run: ${command}\n\n`;
} else {
message = `\nUpdate available${channelSuffix}: ${currentVersion}${cached.latestVersion} — Run: ${command}\n\n`;
}
process.stderr.write(message);
}

View File

@ -97,7 +97,7 @@ describe("setupPathWrapperWorkspace (Unix)", () => {
it("skips wrapper rewrite when version matches", async () => {
mockReadFile
.mockResolvedValueOnce("0.7.0") // version marker matches
.mockResolvedValueOnce("0.8.0") // version marker matches
.mockRejectedValueOnce(new Error("ENOENT")); // AGENTS.md doesn't exist
await setupPathWrapperWorkspace("/workspace");
@ -421,7 +421,7 @@ describe("GH_WRAPPER", () => {
});
it("uses current wrapper version in trace logging", () => {
expect(GH_WRAPPER).toContain("0.7.0");
expect(GH_WRAPPER).toContain("0.8.0");
});
it("logs cache outcomes (hit/miss-stored/miss-negative/miss-error) to trace", () => {

View File

@ -78,6 +78,18 @@ function makeSession(overrides: Partial<Session> & { id?: string } = {}): Sessio
baseBranch: "main",
isDraft: false,
},
prs: [
{
number: 7,
url: "https://github.com/acme/app/pull/7",
title: "feat: todos",
owner: "acme",
repo: "app",
branch: "feat/todos",
baseBranch: "main",
isDraft: false,
},
],
workspacePath: "/tmp/app-worktree",
runtimeHandle: { id: "tmux-app-1", runtimeName: "tmux", data: {} },
agentInfo: null,

View File

@ -42,6 +42,18 @@ function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEven
repo: "app",
isDraft: false,
},
prs: [
{
number: 1,
url: "https://github.com/acme/app/pull/1",
title: "Fix dashboard notifications",
branch: "fix/dashboard-notifications",
baseBranch: "main",
owner: "acme",
repo: "app",
isDraft: false,
},
],
issueId: "AO-1",
issueTitle: "Fix dashboard notifications",
summary: "Fix dashboard notifications",

View File

@ -1040,7 +1040,14 @@ describe("check (single session)", () => {
url: "https://github.com/org/repo/pull/43",
title: "Follow up fix",
});
const mockSCM = createMockSCM({ detectPR: vi.fn().mockResolvedValue(followUpPR) });
const mockSCM = createMockSCM({
detectPR: vi.fn().mockResolvedValue(followUpPR),
// Enrichment cache must show closedPR as closed so the detectPR filter
// can remove it using per-PR state rather than the aggregate lifecycle state.
getPRState: vi.fn().mockImplementation((pr: PRInfo) =>
Promise.resolve(pr.number === closedPR.number ? "closed" : "open"),
),
});
const registry = createMockRegistry({
runtime: plugins.runtime,
agent: plugins.agent,
@ -4473,3 +4480,175 @@ describe("event enrichment", () => {
);
});
});
// ---------------------------------------------------------------------------
// Multi-PR state machine aggregation (issue #1821)
// ---------------------------------------------------------------------------
describe("multi-PR state machine aggregation", () => {
/** Batch enrichment mock returning different data per PR key. */
function mockBatchEnrichmentPerPR(
perPR: Record<
string,
{ state?: string; ciStatus?: string; reviewDecision?: string; mergeable?: boolean }
>,
) {
return vi.fn().mockImplementation(async (prs: PRInfo[]) => {
const result = new Map();
for (const p of prs) {
const key = `${p.owner}/${p.repo}#${p.number}`;
const data = perPR[key] ?? {};
result.set(key, {
state: data.state ?? "open",
ciStatus: data.ciStatus ?? "passing",
reviewDecision: data.reviewDecision ?? "none",
mergeable: data.mergeable ?? false,
});
}
return result;
});
}
it("2.1 — session stays open when only one of two PRs is merged", async () => {
vi.useFakeTimers();
try {
const pr10 = makeMatchingPR({ number: 10, url: "https://github.com/org/my-app/pull/10" });
const pr11 = makeMatchingPR({ number: 11, url: "https://github.com/org/my-app/pull/11" });
const mockSCM = createMockSCM({
enrichSessionsPRBatch: mockBatchEnrichmentPerPR({
"org/my-app#10": { state: "merged" },
"org/my-app#11": { state: "open", ciStatus: "passing", reviewDecision: "approved" },
}),
});
const registry = createMockRegistry({
runtime: plugins.runtime,
agent: plugins.agent,
scm: mockSCM,
});
const session = makeSession({ status: "pr_open", pr: pr10, prs: [pr10, pr11] });
const lm = setupPollCheck("app-1", { session, registry });
lm.start(60_000);
await vi.advanceTimersByTimeAsync(0);
lm.stop();
expect(lm.getStates().get("app-1")).not.toBe("merged");
} finally {
vi.useRealTimers();
}
});
it("2.2 — session merges when ALL PRs are merged", async () => {
vi.useFakeTimers();
try {
const pr10 = makeMatchingPR({ number: 10, url: "https://github.com/org/my-app/pull/10" });
const pr11 = makeMatchingPR({ number: 11, url: "https://github.com/org/my-app/pull/11" });
const mockSCM = createMockSCM({
enrichSessionsPRBatch: mockBatchEnrichmentPerPR({
"org/my-app#10": { state: "merged" },
"org/my-app#11": { state: "merged" },
}),
});
const registry = createMockRegistry({
runtime: plugins.runtime,
agent: plugins.agent,
scm: mockSCM,
});
const session = makeSession({ status: "pr_open", pr: pr10, prs: [pr10, pr11] });
const lm = setupPollCheck("app-1", { session, registry });
lm.start(60_000);
await vi.advanceTimersByTimeAsync(0);
lm.stop();
expect(lm.getStates().get("app-1")).toBe("merged");
} finally {
vi.useRealTimers();
}
});
it("2.3 — ci_failed if ANY PR has failing CI", async () => {
vi.useFakeTimers();
try {
const pr10 = makeMatchingPR({ number: 10, url: "https://github.com/org/my-app/pull/10" });
const pr11 = makeMatchingPR({ number: 11, url: "https://github.com/org/my-app/pull/11" });
const mockSCM = createMockSCM({
enrichSessionsPRBatch: mockBatchEnrichmentPerPR({
"org/my-app#10": { state: "open", ciStatus: "passing", reviewDecision: "approved" },
"org/my-app#11": { state: "open", ciStatus: "failing" },
}),
});
const registry = createMockRegistry({
runtime: plugins.runtime,
agent: plugins.agent,
scm: mockSCM,
});
const session = makeSession({ status: "pr_open", pr: pr10, prs: [pr10, pr11] });
const lm = setupPollCheck("app-1", { session, registry });
lm.start(60_000);
await vi.advanceTimersByTimeAsync(0);
lm.stop();
expect(lm.getStates().get("app-1")).toBe("ci_failed");
} finally {
vi.useRealTimers();
}
});
it("2.4 — review_pending when not all PRs are approved", async () => {
vi.useFakeTimers();
try {
const pr10 = makeMatchingPR({ number: 10, url: "https://github.com/org/my-app/pull/10" });
const pr11 = makeMatchingPR({ number: 11, url: "https://github.com/org/my-app/pull/11" });
const mockSCM = createMockSCM({
enrichSessionsPRBatch: mockBatchEnrichmentPerPR({
"org/my-app#10": { state: "open", ciStatus: "passing", reviewDecision: "approved" },
"org/my-app#11": { state: "open", ciStatus: "passing", reviewDecision: "pending" },
}),
});
const registry = createMockRegistry({
runtime: plugins.runtime,
agent: plugins.agent,
scm: mockSCM,
});
const session = makeSession({ status: "pr_open", pr: pr10, prs: [pr10, pr11] });
const lm = setupPollCheck("app-1", { session, registry });
lm.start(60_000);
await vi.advanceTimersByTimeAsync(0);
lm.stop();
const state = lm.getStates().get("app-1");
expect(state).not.toBe("merged");
expect(state).toBe("review_pending");
} finally {
vi.useRealTimers();
}
});
it("2.5 — single PR session still merges correctly (backwards compat)", async () => {
vi.useFakeTimers();
try {
const pr10 = makeMatchingPR({ number: 10, url: "https://github.com/org/my-app/pull/10" });
const mockSCM = createMockSCM({
enrichSessionsPRBatch: mockBatchEnrichment({ state: "merged", ciStatus: "none" }),
});
const registry = createMockRegistry({
runtime: plugins.runtime,
agent: plugins.agent,
scm: mockSCM,
});
const session = makeSession({ status: "pr_open", pr: pr10 });
const lm = setupPollCheck("app-1", { session, registry });
lm.start(60_000);
await vi.advanceTimersByTimeAsync(0);
lm.stop();
expect(lm.getStates().get("app-1")).toBe("merged");
} finally {
vi.useRealTimers();
}
});
});

View File

@ -21,6 +21,18 @@ const context: NotificationEventContext = {
repo: "app",
isDraft: false,
},
prs: [
{
number: 42,
url: "https://github.com/acme/app/pull/42",
title: "Normalize notifier payloads",
branch: "ao/notifier-payloads",
baseBranch: "main",
owner: "acme",
repo: "app",
isDraft: false,
},
],
issueId: "AO-42",
issueTitle: "Notifier payloads",
summary: "Normalize notifier payloads",

View File

@ -0,0 +1,126 @@
import { describe, it, expect } from "vitest";
import { sessionFromMetadata } from "../utils/session-from-metadata.js";
import { AGENT_REPORT_METADATA_KEYS } from "../agent-report.js";
describe("sessionFromMetadata — multi-PR (issue #1821)", () => {
const baseOptions = { projectId: "my-app" };
it("1.1 — single PR backwards compat: only pr field in metadata", () => {
const session = sessionFromMetadata(
"app-1",
{ pr: "https://github.com/acme/main/pull/10", branch: "feat/pr-10" },
baseOptions,
);
expect(session.pr).not.toBeNull();
expect(session.pr?.number).toBe(10);
expect(session.prs).toHaveLength(1);
expect(session.prs[0]).toBe(session.pr);
});
it("1.2 — multiple PRs from prs field: primary is first", () => {
const session = sessionFromMetadata(
"app-1",
{
prs: "https://github.com/acme/main/pull/10,https://github.com/acme/sub/pull/5",
branch: "feat/pr-10",
},
baseOptions,
);
expect(session.prs).toHaveLength(2);
expect(session.prs[0].number).toBe(10);
expect(session.prs[1].number).toBe(5);
expect(session.pr).toBe(session.prs[0]);
});
it("1.3 — secondary PR gets its own number, not the primary's", () => {
const session = sessionFromMetadata(
"app-1",
{
prs: "https://github.com/acme/main/pull/10,https://github.com/acme/sub/pull/42",
branch: "feat/pr-10",
},
baseOptions,
);
expect(session.prs[0].number).toBe(10);
expect(session.prs[1].number).toBe(42);
});
it("1.4 — isDraft applies only to primary PR, secondary is always false", () => {
const session = sessionFromMetadata(
"app-1",
{
prs: "https://github.com/acme/main/pull/10,https://github.com/acme/sub/pull/11",
branch: "feat/pr-10",
[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT]: "true",
},
baseOptions,
);
expect(session.prs[0].isDraft).toBe(true);
expect(session.prs[1].isDraft).toBe(false);
});
it("1.5 — empty prs field falls back to pr field", () => {
const session = sessionFromMetadata(
"app-1",
{
pr: "https://github.com/acme/main/pull/10",
prs: "",
branch: "feat/pr-10",
},
baseOptions,
);
expect(session.prs).toHaveLength(1);
expect(session.prs[0].number).toBe(10);
});
it("1.6 — no PR fields → pr is null and prs is empty", () => {
const session = sessionFromMetadata("app-1", {}, baseOptions);
expect(session.pr).toBeNull();
expect(session.prs).toHaveLength(0);
});
it("1.7 — prs field takes precedence over pr field when both are present", () => {
const session = sessionFromMetadata(
"app-1",
{
pr: "https://github.com/acme/main/pull/10",
prs: "https://github.com/acme/main/pull/20,https://github.com/acme/main/pull/21",
branch: "feat/pr-20",
},
baseOptions,
);
expect(session.prs).toHaveLength(2);
expect(session.prs[0].number).toBe(20);
expect(session.prs[1].number).toBe(21);
expect(session.pr?.number).toBe(20);
});
it("1.8 — URLs with whitespace around commas are trimmed", () => {
const session = sessionFromMetadata(
"app-1",
{
prs: "https://github.com/acme/main/pull/10 , https://github.com/acme/main/pull/11",
branch: "feat/pr-10",
},
baseOptions,
);
expect(session.prs).toHaveLength(2);
expect(session.prs[0].number).toBe(10);
expect(session.prs[1].number).toBe(11);
});
it("1.9 — owner and repo parsed correctly for each PR", () => {
const session = sessionFromMetadata(
"app-1",
{
prs: "https://github.com/org-a/repo-x/pull/10,https://github.com/org-b/repo-y/pull/99",
branch: "feat/pr-10",
},
baseOptions,
);
expect(session.prs[0].owner).toBe("org-a");
expect(session.prs[0].repo).toBe("repo-x");
expect(session.prs[1].owner).toBe("org-b");
expect(session.prs[1].repo).toBe("repo-y");
});
});

View File

@ -7,6 +7,7 @@ import { createSessionManager } from "../../session-manager.js";
import {
writeMetadata,
readMetadataRaw,
updateMetadata,
} from "../../metadata.js";
import {
type OrchestratorConfig,
@ -480,4 +481,112 @@ describe("claimPR", () => {
const oldOwner = readMetadataRaw(sessionsDir, "app-owner");
expect(oldOwner!["pr"] ?? "").toBe("");
});
// -------------------------------------------------------------------------
// Multi-PR stack behavior (issue #1821)
// -------------------------------------------------------------------------
it("3.1 — claimed PR is pushed to front of prs stack, existing PRs move to back", async () => {
const mockSCM = makeSCM({
resolvePR: vi.fn().mockResolvedValue({
number: 20,
url: "https://github.com/org/my-app/pull/20",
title: "New PR",
owner: "org",
repo: "my-app",
branch: "feat/new-pr",
baseBranch: "main",
isDraft: false,
}),
});
writeMetadata(sessionsDir, "app-1", {
worktree: "/tmp/ws-app-1",
branch: "feat/existing-pr",
status: "pr_open",
project: "my-app",
pr: "https://github.com/org/my-app/pull/10",
runtimeHandle: makeHandle("rt-1"),
});
updateMetadata(sessionsDir, "app-1", {
prs: "https://github.com/org/my-app/pull/10",
});
const sm = createSessionManager({ config, registry: registryWithSCM(mockSCM) });
await sm.claimPR("app-1", "20");
const raw = readMetadataRaw(sessionsDir, "app-1");
expect(raw!["pr"]).toBe("https://github.com/org/my-app/pull/20");
expect(raw!["prs"]).toBe(
"https://github.com/org/my-app/pull/20,https://github.com/org/my-app/pull/10",
);
});
it("3.2 — claimPR on session with no existing prs writes only the claimed PR", async () => {
const mockSCM = makeSCM({
resolvePR: vi.fn().mockResolvedValue({
number: 20,
url: "https://github.com/org/my-app/pull/20",
title: "First PR",
owner: "org",
repo: "my-app",
branch: "feat/new-pr",
baseBranch: "main",
isDraft: false,
}),
});
writeMetadata(sessionsDir, "app-1", {
worktree: "/tmp/ws-app-1",
branch: "feat/new-pr",
status: "working",
project: "my-app",
runtimeHandle: makeHandle("rt-1"),
});
const sm = createSessionManager({ config, registry: registryWithSCM(mockSCM) });
await sm.claimPR("app-1", "20");
const raw = readMetadataRaw(sessionsDir, "app-1");
expect(raw!["pr"]).toBe("https://github.com/org/my-app/pull/20");
expect(raw!["prs"]).toBe("https://github.com/org/my-app/pull/20");
});
it("3.3 — re-claiming the same PR does not duplicate it in prs stack", async () => {
const mockSCM = makeSCM({
resolvePR: vi.fn().mockResolvedValue({
number: 20,
url: "https://github.com/org/my-app/pull/20",
title: "Already claimed PR",
owner: "org",
repo: "my-app",
branch: "feat/new-pr",
baseBranch: "main",
isDraft: false,
}),
});
writeMetadata(sessionsDir, "app-1", {
worktree: "/tmp/ws-app-1",
branch: "feat/new-pr",
status: "pr_open",
project: "my-app",
pr: "https://github.com/org/my-app/pull/20",
runtimeHandle: makeHandle("rt-1"),
});
updateMetadata(sessionsDir, "app-1", {
prs: "https://github.com/org/my-app/pull/20,https://github.com/org/my-app/pull/10",
});
const sm = createSessionManager({ config, registry: registryWithSCM(mockSCM) });
await sm.claimPR("app-1", "20");
const raw = readMetadataRaw(sessionsDir, "app-1");
expect(raw!["pr"]).toBe("https://github.com/org/my-app/pull/20");
// PR#20 must appear exactly once — still at the front, PR#10 still at the back
expect(raw!["prs"]).toBe(
"https://github.com/org/my-app/pull/20,https://github.com/org/my-app/pull/10",
);
expect(raw!["prs"]!.split(",")).toHaveLength(2);
});
});

View File

@ -105,6 +105,7 @@ export function makeSession(overrides: Partial<Session> = {}): Session {
branch: "feat/test",
issueId: null,
pr: null,
prs: [],
workspacePath: "/tmp/ws",
runtimeHandle: { id: "rt-1", runtimeName: "mock", data: {} },
agentInfo: null,
@ -115,6 +116,7 @@ export function makeSession(overrides: Partial<Session> = {}): Session {
return {
...base,
...overrides,
prs: overrides.prs ?? (overrides.pr ? [overrides.pr] : []),
lifecycle: overrides.lifecycle ?? lifecycle,
};
}

View File

@ -35,7 +35,7 @@ function getAoBinDir(): string {
}
/** Current version of wrapper scripts — bump when scripts change */
const WRAPPER_VERSION = "0.7.0";
const WRAPPER_VERSION = "0.8.0";
// =============================================================================
// PATH Builder
@ -529,6 +529,25 @@ case "\$1/\$2" in
if [[ -n "\$pr_url" ]]; then
update_ao_metadata pr "\$pr_url"
update_ao_metadata agentReportedPrUrl "\$pr_url"
# Append to prs field (comma-separated list of all PR URLs for this session).
# Supports multiple PRs per session same repo or different repos.
_ao_meta_f="\${AO_DATA_DIR}/\${AO_SESSION}.json"
[[ -f "\$_ao_meta_f" ]] || _ao_meta_f="\${AO_DATA_DIR}/\${AO_SESSION}"
if head -c1 "\$_ao_meta_f" 2>/dev/null | grep -q '{'; then
existing_prs="\$(jq -r '.prs // empty' "\$_ao_meta_f" 2>/dev/null || echo "")"
else
existing_prs="\$(grep '^prs=' "\$_ao_meta_f" 2>/dev/null | cut -d'=' -f2- || echo "")"
fi
if [[ -z "\$existing_prs" ]]; then
new_prs="\$pr_url"
else
if ! echo ",\$existing_prs," | grep -qF ",\$pr_url,"; then
new_prs="\$existing_prs,\$pr_url"
else
new_prs="\$existing_prs"
fi
fi
update_ao_metadata prs "\$new_prs"
fi
pr_number="\$(printf '%s' "\$pr_url" | grep -Eo '[0-9]+$' | head -1)"
if [[ -n "\$pr_number" ]]; then
@ -777,8 +796,34 @@ if (key === "pr/create" || key === "pr/merge") {
if (key === "pr/create") {
const match = output.match(/https:\\/\\/github\\.com\\/[^/]+\\/[^/]+\\/pull\\/[0-9]+/);
if (match) {
updateAoMetadata("pr", match[0]);
const prUrl = match[0];
updateAoMetadata("pr", prUrl);
updateAoMetadata("status", "pr_open");
// Append to prs field — supports multiple PRs per session
let existingPrs = "";
try {
const aoDir = process.env["AO_DATA_DIR"] || "";
const aoSession = process.env["AO_SESSION"] || "";
if (aoDir && aoSession && /^[a-zA-Z0-9_-]+$/.test(aoSession)) {
let metaFile = path.join(aoDir, aoSession + ".json");
if (!fs.existsSync(metaFile)) metaFile = path.join(aoDir, aoSession);
if (fs.existsSync(metaFile)) {
const raw = fs.readFileSync(metaFile, "utf8");
if (metaFile.endsWith(".json")) {
existingPrs = JSON.parse(raw).prs || "";
} else {
const line = raw.split("\\n").find(l => l.startsWith("prs="));
existingPrs = line ? line.slice(4) : "";
}
}
}
} catch {}
const newPrs = existingPrs
? existingPrs.split(",").map((u) => u.trim()).includes(prUrl)
? existingPrs
: existingPrs + "," + prUrl
: prUrl;
updateAoMetadata("prs", newPrs);
}
} else if (key === "pr/merge") {
updateAoMetadata("status", "merged");

View File

@ -320,26 +320,27 @@ function buildEventContext(
session: Session | ReactionSessionContext,
prEnrichmentCache: Map<string, PREnrichmentData>,
): EventContext {
let pr: EventContext["pr"] = null;
const sessionPRs = "prs" in session && Array.isArray(session.prs) ? session.prs : (session.pr ? [session.pr] : []);
if (session.pr) {
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
const cached = prEnrichmentCache.get(prKey);
pr = {
url: session.pr.url,
const prs: EventContext["prs"] = sessionPRs.map((p) => {
const cached = prEnrichmentCache.get(`${p.owner}/${p.repo}#${p.number}`);
return {
url: p.url,
title: cached?.title ?? null,
number: session.pr.number,
branch: session.pr.branch,
baseBranch: session.pr.baseBranch,
owner: session.pr.owner,
repo: session.pr.repo,
isDraft: session.pr.isDraft,
number: p.number,
branch: p.branch,
baseBranch: p.baseBranch,
owner: p.owner,
repo: p.repo,
isDraft: p.isDraft,
};
}
});
const pr = prs[0] ?? null;
return {
pr,
prs,
issueId: session.issueId,
issueTitle: session.metadata["issueTitle"] ?? null,
summary: session.agentInfo?.summary ?? null,
@ -554,16 +555,21 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
reposByPlugin.set(pluginKey, new Set());
}
reposByPlugin.get(pluginKey)!.add(project.repo);
if (!session.pr) continue;
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
if (seenPRKeys.has(prKey)) continue;
seenPRKeys.add(prKey);
const pluginPRs = prsByPlugin.get(pluginKey);
if (pluginPRs) {
pluginPRs.push(session.pr);
if (session.prs.length === 0) continue;
// Loop over all PRs in the session — supports multi-repo sessions
// where an agent opened PRs on multiple repos.
for (const pr of session.prs) {
const actualPRRepo = `${pr.owner}/${pr.repo}`;
if (actualPRRepo !== project.repo) {
reposByPlugin.get(pluginKey)!.add(actualPRRepo);
}
const prKey = `${pr.owner}/${pr.repo}#${pr.number}`;
if (seenPRKeys.has(prKey)) continue;
seenPRKeys.add(prKey);
const pluginPRs = prsByPlugin.get(pluginKey);
if (pluginPRs) {
pluginPRs.push(pr);
}
}
}
@ -682,9 +688,18 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
continue;
if (session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator"))
continue;
// Skip detectPR only if we already have a PR on the configured project repo.
// This allows detecting additional PRs on different repos (multi-repo support).
const trackedRepos = new Set(session.prs.map((p) => `${p.owner}/${p.repo}`));
const projectRepoForDetect = config.projects[session.projectId]?.repo;
// primaryPR.branch is always the session branch (metadata doesn't store per-PR branches),
// so use the lifecycle closed-state alone to allow re-detection after a PR is rejected.
const primaryPRIsClosed = session.lifecycle.pr.state === "closed";
if (
session.pr &&
!(session.lifecycle.pr.state === "closed" && session.pr.branch !== session.branch)
session.prs.length > 0 &&
projectRepoForDetect &&
trackedRepos.has(projectRepoForDetect) &&
!primaryPRIsClosed
) {
continue;
}
@ -701,9 +716,39 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
try {
const detectedPR = await scm.detectPR(session, project);
if (detectedPR) {
session.pr = detectedPR;
// Track by owner/repo/number — allows multiple PRs on the same repo
// in the same session (e.g. agent opens PR #10 and PR #11 both on acme/main-app).
// Only skip if we already have this exact PR number on this exact repo.
// If the existing PR on the same repo is closed, replace it with the new one.
const alreadyTracked = session.prs.some(
(p) =>
p.owner === detectedPR.owner &&
p.repo === detectedPR.repo &&
p.number === detectedPR.number
);
if (!alreadyTracked) {
// Remove any closed PRs on the same repo before adding the new one.
// Open PRs on the same repo are kept — multiple open PRs per repo are valid.
session.prs = session.prs
.filter(
(p) =>
!(
p.owner === detectedPR.owner &&
p.repo === detectedPR.repo &&
p.number !== detectedPR.number &&
prEnrichmentCache.get(`${p.owner}/${p.repo}#${p.number}`)?.state === "closed"
)
)
.concat(detectedPR);
}
// pr is always the primary (first) PR
session.pr = session.prs[0] ?? detectedPR;
const sessionsDir = getProjectSessionsDir(session.projectId);
updateMetadata(sessionsDir, session.id, { pr: detectedPR.url });
const allPrUrls = session.prs.map((p) => p.url).join(",");
updateMetadata(sessionsDir, session.id, {
pr: session.pr.url,
prs: allPrUrls,
});
recordActivityEvent({
projectId: session.projectId,
sessionId: session.id,
@ -756,36 +801,75 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
if (!session.pr) continue;
const project = config.projects[session.projectId];
if (!project) continue;
const sessionsDir = getProjectSessionsDir(session.projectId);
const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`;
const cached = prEnrichmentCache.get(prKey);
if (!cached) continue;
if (cached) {
const blob = JSON.stringify({
state: cached.state,
ciStatus: cached.ciStatus,
reviewDecision: cached.reviewDecision,
mergeable: cached.mergeable,
title: cached.title,
additions: cached.additions,
deletions: cached.deletions,
isDraft: cached.isDraft,
hasConflicts: cached.hasConflicts,
isBehind: cached.isBehind,
blockers: cached.blockers,
ciChecks: cached.ciChecks?.map((c) => ({
name: c.name,
status: c.status,
url: c.url,
})),
enrichedAt: new Date().toISOString(),
});
if (session.metadata["prEnrichment"] !== blob) {
updateMetadata(sessionsDir, session.id, { prEnrichment: blob });
session.metadata["prEnrichment"] = blob;
}
// Keep in-memory isDraft in sync with enrichment data
if (cached.isDraft !== undefined && session.pr) {
session.pr.isDraft = cached.isDraft;
}
}
const blob = JSON.stringify({
state: cached.state,
ciStatus: cached.ciStatus,
reviewDecision: cached.reviewDecision,
mergeable: cached.mergeable,
title: cached.title,
additions: cached.additions,
deletions: cached.deletions,
isDraft: cached.isDraft,
hasConflicts: cached.hasConflicts,
isBehind: cached.isBehind,
blockers: cached.blockers,
ciChecks: cached.ciChecks?.map((c) => ({
name: c.name,
status: c.status,
url: c.url,
})),
enrichedAt: new Date().toISOString(),
});
if (session.metadata["prEnrichment"] === blob) continue;
const sessionsDir = getProjectSessionsDir(session.projectId);
updateMetadata(sessionsDir, session.id, { prEnrichment: blob });
session.metadata["prEnrichment"] = blob;
for (let i = 1; i < session.prs.length; i++) {
const secondaryPR = session.prs[i];
if (!secondaryPR) continue;
const secondaryKey = `${secondaryPR.owner}/${secondaryPR.repo}#${secondaryPR.number}`;
const secondaryCached = prEnrichmentCache.get(secondaryKey);
if (!secondaryCached) continue;
const secondaryBlob = JSON.stringify({
state: secondaryCached.state,
ciStatus: secondaryCached.ciStatus,
reviewDecision: secondaryCached.reviewDecision,
mergeable: secondaryCached.mergeable,
title: secondaryCached.title,
additions: secondaryCached.additions,
deletions: secondaryCached.deletions,
isDraft: secondaryCached.isDraft,
hasConflicts: secondaryCached.hasConflicts,
isBehind: secondaryCached.isBehind,
blockers: secondaryCached.blockers,
ciChecks: secondaryCached.ciChecks?.map((c) => ({
name: c.name,
status: c.status,
url: c.url,
})),
enrichedAt: new Date().toISOString(),
});
const metaKey = `prEnrichment_${i}`;
if (session.metadata[metaKey] !== secondaryBlob) {
updateMetadata(sessionsDir, session.id, { [metaKey]: secondaryBlob });
session.metadata[metaKey] = secondaryBlob;
}
// Keep in-memory isDraft in sync with enrichment data
if (secondaryCached.isDraft !== undefined) {
secondaryPR.isDraft = secondaryCached.isDraft;
}
}
}
}
@ -1236,13 +1320,64 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
: false;
if (cachedData) {
return commit(
resolvePREnrichmentDecision(cachedData, {
shouldEscalateIdleToStuck,
idleWasBlocked,
activityEvidence,
}),
);
// When session has multiple PRs, aggregate enrichment across all of them.
// ci_failed if ANY fails; approved/merged only when ALL pass.
if (session.prs.length > 1) {
const allEnrichments = session.prs
.map((p) => prEnrichmentCache.get(`${p.owner}/${p.repo}#${p.number}`))
.filter((e): e is PREnrichmentData => e !== undefined);
if (allEnrichments.length === session.prs.length) {
const aggregated: PREnrichmentData = {
ciStatus: allEnrichments.some((e) => e.ciStatus === "failing")
? "failing"
: allEnrichments.every((e) => e.ciStatus === "passing" || e.ciStatus === "none")
? "passing"
: "pending",
reviewDecision: allEnrichments.some(
(e) => e.reviewDecision === "changes_requested",
)
? "changes_requested"
: allEnrichments.every((e) => e.reviewDecision === "approved")
? "approved"
: allEnrichments.every((e) => e.reviewDecision === "none")
? "none"
: "pending",
state: allEnrichments.every((e) => e.state === "merged")
? "merged"
: allEnrichments.some((e) => e.state === "open")
? "open"
: "closed",
mergeable: allEnrichments.every((e) => e.mergeable),
blockers: [...new Set(allEnrichments.flatMap((e) => e.blockers ?? []))],
title: cachedData.title,
additions: cachedData.additions,
deletions: cachedData.deletions,
isDraft: allEnrichments.some((e) => e.isDraft),
hasConflicts: allEnrichments.some((e) => e.hasConflicts),
isBehind: allEnrichments.some((e) => e.isBehind),
};
return commit(
resolvePREnrichmentDecision(aggregated, {
shouldEscalateIdleToStuck,
idleWasBlocked,
activityEvidence,
}),
);
}
}
// Partial cache miss for multi-PR session: never decide on primary PR
// alone — fall through to the live-API check that verifies all PRs.
if (session.prs.length <= 1) {
return commit(
resolvePREnrichmentDecision(cachedData, {
shouldEscalateIdleToStuck,
idleWasBlocked,
activityEvidence,
}),
);
}
// intentional fall-through to live-API block below
}
// Batch enrichment cache miss — fall back to getPRState for terminal
@ -1250,19 +1385,38 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
// delayed cleanup. Non-terminal state updates wait for the next batch
// cycle (30s) to avoid ~110 individual REST calls per 15-min window.
try {
const prState = await scm.getPRState(session.pr);
if (prState === "merged" || prState === "closed") {
return commit(
resolvePRLiveDecision({
prState,
ciStatus: "none",
reviewDecision: "none",
mergeable: false,
shouldEscalateIdleToStuck,
idleWasBlocked,
activityEvidence,
}),
);
if (session.prs.length > 1) {
// Multi-PR: only terminate when ALL PRs are in a terminal state.
const states = await Promise.all(session.prs.map((p) => scm.getPRState(p)));
if (states.every((s) => s === "merged" || s === "closed")) {
const prState = states.every((s) => s === "merged") ? "merged" : "closed";
return commit(
resolvePRLiveDecision({
prState,
ciStatus: "none",
reviewDecision: "none",
mergeable: false,
shouldEscalateIdleToStuck,
idleWasBlocked,
activityEvidence,
}),
);
}
} else {
const prState = await scm.getPRState(session.pr);
if (prState === "merged" || prState === "closed") {
return commit(
resolvePRLiveDecision({
prState,
ciStatus: "none",
reviewDecision: "none",
mergeable: false,
shouldEscalateIdleToStuck,
idleWasBlocked,
activityEvidence,
}),
);
}
}
} catch (err) {
// Best-effort — batch will retry next cycle. Record AE evidence so
@ -1776,6 +1930,47 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
if (session.metadata["prReviewComments"] !== reviewBlob) {
updateSessionMetadata(session, { prReviewComments: reviewBlob });
}
// Persist per-PR review comment blobs for secondary PRs so the dashboard
// can enrich them independently (prReviewComments_1, prReviewComments_2, …).
for (let i = 1; i < session.prs.length; i++) {
const secondaryPR = session.prs[i];
if (!secondaryPR) continue;
let secondaryThreads: ReviewComment[];
let secondaryReviews: ReviewSummary[];
try {
if (scm.getReviewThreads) {
const result = await scm.getReviewThreads(secondaryPR);
secondaryThreads = result.threads;
secondaryReviews = result.reviews;
} else {
secondaryThreads = await scm.getPendingComments(secondaryPR);
secondaryReviews = [];
}
} catch {
continue;
}
const secondaryUnresolved = secondaryThreads.filter((c) => !c.isBot);
const secondaryBlob = JSON.stringify({
unresolvedThreads: secondaryUnresolved.length,
unresolvedComments: secondaryUnresolved.map((c) => ({
url: c.url,
path: c.path ?? "",
author: c.author,
body: c.body,
})),
reviews: secondaryReviews.map((r) => ({
author: r.author,
state: r.state,
body: r.body,
})),
commentsUpdatedAt: new Date().toISOString(),
});
const reviewMetaKey = `prReviewComments_${i}`;
if (session.metadata[reviewMetaKey] !== secondaryBlob) {
updateSessionMetadata(session, { [reviewMetaKey]: secondaryBlob });
}
}
}
const pendingComments = allThreads.filter((c) => !c.isBot);

View File

@ -23,6 +23,8 @@ export interface NotificationPRContext {
export interface NotificationEventContext {
pr: NotificationPRContext | null;
/** All PRs for this session. Single-PR sessions have exactly one entry (same as pr). */
prs: NotificationPRContext[];
issueId: string | null;
issueTitle: string | null;
summary: string | null;

View File

@ -122,6 +122,7 @@ export async function validateSession(
branch: normalizedMetadata["branch"] ?? null,
issueId: normalizedMetadata["issue"] ?? null,
pr: null,
prs: [],
workspacePath,
runtimeHandle,
agentInfo: null,

View File

@ -1484,6 +1484,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
branch,
issueId: spawnConfig.issueId ?? null,
pr: null,
prs: [],
workspacePath,
runtimeHandle: handle,
agentInfo: null,
@ -1971,6 +1972,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
branch,
issueId: null,
pr: null,
prs: [],
workspacePath,
runtimeHandle: handle,
agentInfo: null,
@ -2658,11 +2660,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
const plugins = resolvePlugins(project);
let shouldKill = false;
// Check if PR was closed without merging.
if (session.pr && plugins.scm) {
// Check if all tracked PRs are closed without merging.
// For multi-PR sessions, keep alive as long as any PR is still open.
const prsToCheck = session.prs.length > 0 ? session.prs : session.pr ? [session.pr] : [];
if (prsToCheck.length > 0 && plugins.scm) {
try {
const prState = await plugins.scm.getPRState(session.pr);
if (prState === PR_STATE.CLOSED) {
const states = await Promise.all(
prsToCheck.map((pr) => plugins.scm!.getPRState(pr)),
);
if (states.every((state) => state === PR_STATE.CLOSED)) {
shouldKill = true;
}
} catch {
@ -3157,7 +3163,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
if (!otherRaw || isOrchestratorSessionRecord(sessionName, otherRaw, project.sessionPrefix))
continue;
const samePr = otherRaw["pr"] === pr.url;
const otherPrUrls = new Set<string>(
[
otherRaw["pr"],
...(typeof otherRaw["prs"] === "string" ? otherRaw["prs"].split(",") : []),
]
.map((u) => (typeof u === "string" ? u.trim() : ""))
.filter(Boolean),
);
const samePr = otherPrUrls.has(pr.url);
const sameBranch =
otherRaw["branch"] === pr.branch && (otherRaw["prAutoDetect"] ?? "on") !== "off" && otherRaw["prAutoDetect"] !== "false";
@ -3182,11 +3196,33 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
next.pr.url = pr.url;
next.pr.lastObservedAt = new Date().toISOString();
});
// Stack: push claimed PR to front — it becomes primary (prs[0]) on next load.
// Filter out duplicates, keep all other tracked PRs at the back.
const existingPrs = raw["prs"] ?? raw["pr"] ?? "";
const otherPrs = existingPrs
.split(",")
.map((u) => u.trim())
.filter((u) => u && u !== pr.url)
.join(",");
const newPrs = otherPrs ? `${pr.url},${otherPrs}` : pr.url;
// Clear stale positional enrichment blobs — claimPR reorders prs[] so
// index-keyed blobs no longer match. Lifecycle poll rewrites them within ~30s.
const staleEnrichmentKeys: Record<string, string> = {
prEnrichment: "",
prReviewComments: "",
};
for (const key of Object.keys(raw)) {
if (/^prEnrichment_\d+$/.test(key) || /^prReviewComments_\d+$/.test(key)) {
staleEnrichmentKeys[key] = "";
}
}
updateMetadata(sessionsDir, sessionId, {
pr: pr.url,
prs: newPrs,
status: deriveLegacyStatus(claimLifecycle),
branch: pr.branch,
prAutoDetect: "",
...staleEnrichmentKeys,
...lifecycleMetadataUpdates(raw, claimLifecycle),
});
invalidateCache();
@ -3208,6 +3244,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
});
updateMetadata(sessionsDir, previousSessionId, {
pr: "",
prs: "",
prAutoDetect: "false",
...(PR_TRACKING_STATUSES.has(previousRaw["status"] ?? "")
? { status: "working" }

View File

@ -305,6 +305,11 @@ export interface Session {
/** PR info (once PR is created) */
pr: PRInfo | null;
/** All PRs opened by this session (across multiple repos). Always in sync with pr
* single-PR sessions have prs = [pr], no-PR sessions have prs = [].
* Populated from metadata field "prs" (comma-separated URLs) on load. */
prs: PRInfo[];
/** Workspace path on disk */
workspacePath: string | null;

View File

@ -71,20 +71,24 @@ export function readUpdateCheckCacheRaw(): UpdateCheckCacheRaw | null {
* The currently-installed `@aoagents/ao` version.
*
* Tries the wrapper package first (the canonical version users see). Falls
* back to `@aoagents/ao-web` for dev mode where the wrapper isn't always in
* `node_modules` the dashboard ships in lockstep with `@aoagents/ao` (the
* changeset linked group), so the web version is a safe proxy.
* back to the CLI package, then `@aoagents/ao-web` for dev mode where the
* wrapper isn't always in `node_modules` these packages ship in lockstep
* with `@aoagents/ao` (the changeset linked group), so either is a safe proxy.
*
* Final fallback returns `"0.0.0"` so callers always have a string to
* `isVersionOutdated` against.
*/
export function getInstalledAoVersion(): string {
const require = createRequire(fileURLToPath(import.meta.url));
const candidates = ["@aoagents/ao/package.json", "@aoagents/ao-web/package.json"];
const candidates = [
"@aoagents/ao/package.json",
"@aoagents/ao-cli/package.json",
"@aoagents/ao-web/package.json",
];
for (const candidate of candidates) {
try {
const pkg = require(candidate) as { version?: unknown };
if (typeof pkg.version === "string") return pkg.version;
if (typeof pkg.version === "string" && pkg.version !== "0.0.0") return pkg.version;
} catch {
// try next candidate
}

View File

@ -1,5 +1,6 @@
import type {
ActivitySignal,
PRInfo,
RuntimeHandle,
Session,
SessionId,
@ -63,6 +64,33 @@ export function sessionFromMetadata(
const prUrl = lifecycle.pr.url ?? meta["pr"];
const prIsDraft = meta[AGENT_REPORT_METADATA_KEYS.PR_IS_DRAFT] === "true";
// Build a PRInfo object from a single URL string.
// isDraft defaults to false for secondary PRs — only the primary PR carries the flag.
const buildPRInfo = (url: string, isDraft = false, lifecyclePrNumber?: number | null): PRInfo => {
const parsed = parsePrFromUrl(url);
return {
number: parsed?.number || lifecyclePrNumber || 0,
url,
title: "",
owner: parsed?.owner ?? "",
repo: parsed?.repo ?? "",
branch: meta["branch"] ?? "",
baseBranch: "",
isDraft,
};
};
// Build prs[] from metadata.
// New sessions write "prs" as comma-separated URLs for all PRs.
// Old sessions only have a single "pr" field — wrap it for backwards compat.
const prsRaw = meta["prs"];
const lifecyclePrNumber = lifecycle.pr.number ?? null;
const prs: PRInfo[] = prsRaw
? prsRaw.split(",").map((u, i) => buildPRInfo(u.trim(), i === 0 ? prIsDraft : false, i === 0 ? lifecyclePrNumber : null)).filter((p) => Boolean(p.url))
: prUrl
? [buildPRInfo(prUrl, prIsDraft, lifecyclePrNumber)]
: [];
return {
id: sessionId,
projectId: meta["project"] ?? options.projectId ?? "",
@ -72,21 +100,8 @@ export function sessionFromMetadata(
lifecycle,
branch: meta["branch"] || null,
issueId: meta["issue"] || null,
pr: prUrl
? (() => {
const parsed = parsePrFromUrl(prUrl);
return {
number: lifecycle.pr.number ?? parsed?.number ?? 0,
url: prUrl,
title: "",
owner: parsed?.owner ?? "",
repo: parsed?.repo ?? "",
branch: meta["branch"] ?? "",
baseBranch: "",
isDraft: prIsDraft,
};
})()
: null,
pr: prs[0] ?? null,
prs,
workspacePath: meta["worktree"] || options.workspacePathFallback || null,
runtimeHandle: lifecycle.runtime.handle ?? runtimeHandle,
agentInfo: meta["summary"] ? { summary: meta["summary"], agentSessionId: null } : null,

View File

@ -52,6 +52,7 @@ export function makeSession(overrides: Partial<Session> = {}): Session {
branch: "feat/test",
issueId: null,
pr: null,
prs: [],
workspacePath: "/tmp/workspace",
runtimeHandle: null,
agentInfo: null,

View File

@ -40,6 +40,7 @@ export function makeSession(
branch: null,
issueId: null,
pr: null,
prs: [],
workspacePath,
runtimeHandle: handle,
agentInfo: null,

View File

@ -79,6 +79,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
branch: "feat/test",
issueId: null,
pr: null,
prs: [],
workspacePath: "/workspace/test",
runtimeHandle: null,
agentInfo: null,

View File

@ -47,6 +47,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
branch: "main",
issueId: null,
pr: null,
prs: [],
workspacePath,
runtimeHandle: handle,
agentInfo: null,

View File

@ -99,6 +99,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
branch: "feat/test",
issueId: null,
pr: null,
prs: [],
workspacePath: "/workspace/test-project",
runtimeHandle: null,
agentInfo: null,

View File

@ -173,6 +173,33 @@ if [[ "$clean_command" =~ ^gh[[:space:]]+pr[[:space:]]+create ]]; then
if [[ -n "$pr_url" ]]; then
update_metadata_key "pr" "$pr_url"
# Append to prs field (comma-separated list of all PR URLs for this session).
# Supports multiple PRs per session same repo or different repos.
existing_prs=""
if is_json_metadata; then
if command -v jq &>/dev/null; then
existing_prs=$(jq -r '.prs // empty' "$metadata_file" 2>/dev/null || echo "")
else
existing_prs=$(node -e "
const fs = require('fs');
const d = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
process.stdout.write(d.prs || '');
" "$metadata_file" 2>/dev/null || echo "")
fi
else
existing_prs=$(grep '^prs=' "$metadata_file" 2>/dev/null | cut -d'=' -f2- || echo "")
fi
if [[ -z "$existing_prs" ]]; then
new_prs="$pr_url"
else
# Only append if not already present (exact comma-delimited match to avoid /pull/1 matching /pull/10)
if ! echo ",$existing_prs," | grep -qF ",$pr_url,"; then
new_prs="$existing_prs,$pr_url"
else
new_prs="$existing_prs"
fi
fi
update_metadata_key "prs" "$new_prs"
update_metadata_key "status" "pr_open"
echo '{"systemMessage": "Updated metadata: PR created at '"$pr_url"'"}'
exit 0
@ -340,7 +367,23 @@ if (/^gh\\s+pr\\s+create/.test(cleanCommand)) {
const prMatch = output.match(/https:\\/\\/github[.]com\\/[^/]+\\/[^/]+\\/pull\\/\\d+/);
if (prMatch) {
const prUrl = prMatch[0];
let existingPrs = "";
try {
const raw = readFileSync(metadataFile, "utf-8");
if (metadataFile.endsWith(".json")) {
existingPrs = JSON.parse(raw).prs || "";
} else {
const prsLine = raw.split("\\n").find((l) => l.startsWith("prs="));
existingPrs = prsLine ? prsLine.slice(4) : "";
}
} catch {}
const newPrs = !existingPrs
? prUrl
: existingPrs.split(",").map((u) => u.trim()).includes(prUrl)
? existingPrs
: existingPrs + "," + prUrl;
updateMetadataKey("pr", prUrl);
updateMetadataKey("prs", newPrs);
updateMetadataKey("status", "pr_open");
process.stdout.write(JSON.stringify({ systemMessage: "Updated metadata: PR created at " + prUrl }) + "\\n");
process.exit(0);

View File

@ -121,6 +121,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
branch: "feat/test",
issueId: null,
pr: null,
prs: [],
workspacePath: "/workspace/test",
runtimeHandle: null,
agentInfo: null,

View File

@ -81,6 +81,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
branch: "feat/test",
issueId: null,
pr: null,
prs: [],
workspacePath: "/workspace/test",
runtimeHandle: null,
agentInfo: null,

View File

@ -146,6 +146,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
branch: "feat/test",
issueId: null,
pr: null,
prs: [],
workspacePath: "/workspace/test",
runtimeHandle: null,
agentInfo: null,

View File

@ -75,6 +75,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
branch: "feat/test",
issueId: null,
pr: null,
prs: [],
workspacePath: "/workspace/test",
runtimeHandle: null,
agentInfo: null,

View File

@ -41,6 +41,18 @@ function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEven
repo: "app",
isDraft: false,
},
prs: [
{
number: 1,
url: "https://github.com/acme/app/pull/1",
title: "Demo PR",
branch: "demo/pr",
baseBranch: "main",
owner: "acme",
repo: "app",
isDraft: false,
},
],
issueId: null,
issueTitle: null,
summary: "Demo session",

View File

@ -36,6 +36,18 @@ const prContext: NotificationEventContext = {
repo: "agent-orchestrator",
isDraft: false,
},
prs: [
{
number: 1579,
url: "https://github.com/ComposioHQ/agent-orchestrator/pull/1579",
title: "Normalize AO notifier payloads",
branch: "ao/demo-notifier-harness",
baseBranch: "main",
owner: "ComposioHQ",
repo: "agent-orchestrator",
isDraft: false,
},
],
issueId: "AO-1579",
issueTitle: "Make AO notification payloads API-grade",
summary: "Normalize AO notifier payloads",

View File

@ -73,6 +73,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
branch: "feat/my-feature",
issueId: null,
pr: null,
prs: [],
workspacePath: "/tmp/repo",
runtimeHandle: null,
agentInfo: null,

View File

@ -69,6 +69,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
branch: "feat/my-feature",
issueId: null,
pr: null,
prs: [],
workspacePath: "/tmp/repo",
runtimeHandle: null,
agentInfo: null,

View File

@ -37,6 +37,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
branch: "feat/test",
issueId: null,
pr: null,
prs: [],
workspacePath: "/tmp/workspace",
runtimeHandle: null,
agentInfo: null,

View File

@ -23,6 +23,7 @@ function makeSession(overrides: Partial<Session> = {}): Session {
branch: "feat/test",
issueId: null,
pr: null,
prs: [],
workspacePath: "/tmp/workspace",
runtimeHandle: null,
agentInfo: null,

View File

@ -59,9 +59,11 @@
"@aoagents/ao-plugin-tracker-github": "workspace:*",
"@aoagents/ao-plugin-tracker-linear": "workspace:*",
"@aoagents/ao-plugin-workspace-worktree": "workspace:*",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xterm/addon-fit": "0.12.0-beta.256",
"@xterm/addon-unicode11": "0.10.0-beta.256",
"@xterm/addon-web-links": "0.13.0-beta.256",
"@xterm/addon-webgl": "0.20.0-beta.255",
"@xterm/xterm": "6.1.0-beta.256",
"next": "^15.1.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -58,6 +58,7 @@ function makeSession(overrides: Partial<Session> & { id: string }): Session {
lastActivityAt: new Date(),
metadata: {},
...overrides,
prs: overrides.prs ?? (overrides.pr ? [overrides.pr] : []),
};
}

View File

@ -45,6 +45,7 @@ function makeSession(overrides: Partial<Session> & { id: string }): Session {
lastActivityAt: new Date(),
metadata: {},
...overrides,
prs: overrides.prs ?? (overrides.pr ? [overrides.pr] : []),
};
}

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import { CIBadge, CICheckList } from "@/components/CIBadge";
import { PRStatus } from "@/components/PRStatus";
import { SessionCard } from "@/components/SessionCard";
@ -286,7 +286,7 @@ describe("SessionCard", () => {
expect(onRestore).toHaveBeenCalledWith("backend-1");
});
it("shows merge button when PR is mergeable", () => {
it("shows the PR link and approved detail in the footer when mergeable", () => {
const pr = makePR({
number: 42,
state: "open",
@ -300,7 +300,8 @@ describe("SessionCard", () => {
});
const session = makeSession({ status: "mergeable", activity: "idle", pr });
render(<SessionCard session={session} />);
expect(screen.getByRole("button", { name: /merge/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "PR #42" })).toBeInTheDocument();
expect(screen.getByText("approved")).toBeInTheDocument();
});
it("calls onMerge when merge button is clicked", () => {
@ -319,10 +320,10 @@ describe("SessionCard", () => {
const session = makeSession({ status: "mergeable", activity: "idle", pr });
render(<SessionCard session={session} onMerge={onMerge} />);
fireEvent.click(screen.getByRole("button", { name: /merge/i }));
expect(onMerge).toHaveBeenCalledWith(42);
expect(onMerge).toHaveBeenCalledWith(42, "acme", "app");
});
it("renders passing CI check chips as hyperlinks when url is present", () => {
it("does not render passing CI check chips on the card", () => {
const pr = makePR({
state: "open",
ciStatus: "passing",
@ -332,8 +333,6 @@ describe("SessionCard", () => {
status: "passed",
url: "https://github.com/owner/repo/runs/111",
},
{ name: "tests", status: "passed", url: "https://github.com/owner/repo/runs/222" },
{ name: "no-url-check", status: "passed" },
],
reviewDecision: "approved",
mergeability: {
@ -347,48 +346,12 @@ describe("SessionCard", () => {
const session = makeSession({ status: "mergeable", activity: "idle", pr });
render(<SessionCard session={session} />);
const lintLink = screen.getByRole("link", { name: /lint-and-type-checks/ });
expect(lintLink).toHaveAttribute("href", "https://github.com/owner/repo/runs/111");
expect(lintLink).toHaveAttribute("target", "_blank");
expect(lintLink).toHaveAttribute("rel", "noopener noreferrer");
const testsLink = screen.getByRole("link", { name: /^tests$/ });
expect(testsLink).toHaveAttribute("href", "https://github.com/owner/repo/runs/222");
// Check without url should still render as plain text, not a link
expect(screen.queryByRole("link", { name: /no-url-check/ })).not.toBeInTheDocument();
expect(screen.getByText("no-url-check")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: /lint-and-type-checks/ })).not.toBeInTheDocument();
// The terse footer detail conveys CI/merge state instead.
expect(screen.getByText("approved")).toBeInTheDocument();
});
it("stops propagation when clicking a passing CI chip link", () => {
const onClick = vi.fn();
const pr = makePR({
state: "open",
ciStatus: "passing",
ciChecks: [
{ name: "build", status: "passed", url: "https://github.com/owner/repo/runs/333" },
],
reviewDecision: "approved",
mergeability: {
mergeable: true,
ciPassing: true,
approved: true,
noConflicts: true,
blockers: [],
},
});
const session = makeSession({ status: "mergeable", activity: "idle", pr });
render(
<div onClick={onClick}>
<SessionCard session={session} />
</div>,
);
const link = screen.getByRole("link", { name: /build/ });
fireEvent.click(link);
expect(onClick).not.toHaveBeenCalled();
});
it("shows CI failing alert", () => {
it("shows a terse CI-failed detail with a tone in the footer", () => {
const pr = makePR({
state: "open",
ciStatus: "failing",
@ -407,12 +370,11 @@ describe("SessionCard", () => {
});
const session = makeSession({ status: "ci_failed", activity: "idle", pr });
render(<SessionCard session={session} />);
expect(screen.getByRole("link", { name: "1 check failing" })).toBeInTheDocument();
const detail = screen.getByText("1 check failed");
expect(detail).toHaveAttribute("data-tone", "fail");
});
it("shows CI status unknown when ciStatus is failing but no failed checks", () => {
// This happens when GitHub API fails - getCISummary returns "failing"
// but getCIChecks returns empty array
it("shows a generic CI-failed detail when ciStatus is failing but no failed checks", () => {
const pr = makePR({
state: "open",
ciStatus: "failing",
@ -427,15 +389,15 @@ describe("SessionCard", () => {
},
});
const session = makeSession({ status: "ci_failed", activity: "idle", pr });
render(<SessionCard session={session} />);
expect(screen.getByText("CI unknown")).toBeInTheDocument();
// Should NOT show "0 CI check failing"
expect(screen.queryByText(/0.*CI check.*failing/i)).not.toBeInTheDocument();
// Should NOT show "ask to fix" action for unknown CI
const { container } = render(<SessionCard session={session} />);
const detail = container.querySelector(".session-card__footer-detail");
expect(detail).toHaveTextContent("CI failed");
expect(detail).toHaveAttribute("data-tone", "fail");
// No alert rows / action buttons on the informational card.
expect(screen.queryByText("ask to fix")).not.toBeInTheDocument();
});
it("shows changes requested alert", () => {
it("shows a changes-requested footer detail", () => {
const pr = makePR({
state: "open",
ciStatus: "passing",
@ -450,10 +412,11 @@ describe("SessionCard", () => {
});
const session = makeSession({ activity: "idle", pr });
render(<SessionCard session={session} />);
expect(screen.getByText("changes requested")).toBeInTheDocument();
const detail = screen.getByText("changes requested");
expect(detail).toHaveAttribute("data-tone", "amber");
});
it("shows needs review alert", () => {
it("shows a CI-passed footer detail for a green, not-yet-mergeable PR", () => {
const pr = makePR({
state: "open",
ciStatus: "passing",
@ -468,10 +431,11 @@ describe("SessionCard", () => {
});
const session = makeSession({ activity: "idle", pr });
render(<SessionCard session={session} />);
expect(screen.getByText("needs review")).toBeInTheDocument();
const detail = screen.getByText("CI passed");
expect(detail).toHaveAttribute("data-tone", "green");
});
it("shows unresolved comments alert with count", () => {
it("shows an unresolved-comments footer detail with count", () => {
const pr = makePR({
state: "open",
ciStatus: "passing",
@ -492,11 +456,10 @@ describe("SessionCard", () => {
});
const session = makeSession({ activity: "idle", pr });
render(<SessionCard session={session} />);
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("unresolved comments")).toBeInTheDocument();
expect(screen.getByText("3 comments")).toBeInTheDocument();
});
it("shows action buttons when agent is idle", () => {
it("does not render alert rows or action buttons on the informational card", () => {
const pr = makePR({
state: "open",
ciStatus: "failing",
@ -511,33 +474,15 @@ describe("SessionCard", () => {
},
});
const session = makeSession({ activity: "idle", pr });
render(<SessionCard session={session} />);
expect(screen.getByRole("button", { name: "Ask to fix" })).toBeInTheDocument();
const { container } = render(<SessionCard session={session} />);
expect(screen.queryByRole("button", { name: "Ask to fix" })).not.toBeInTheDocument();
expect(container.querySelector(".alert-row")).toBeNull();
});
it("shows action buttons even when agent is active", () => {
const pr = makePR({
state: "open",
ciStatus: "failing",
ciChecks: [{ name: "test", status: "failed" }],
reviewDecision: "approved",
mergeability: {
mergeable: false,
ciPassing: false,
approved: true,
noConflicts: true,
blockers: [],
},
});
const session = makeSession({ activity: "active", pr });
render(<SessionCard session={session} />);
expect(screen.getByRole("button", { name: "Ask to fix" })).toBeInTheDocument();
});
it("shows issue details in the compact card footer", () => {
it("shows the no-PR-yet footer detail when the session has no PR", () => {
const session = makeSession({ id: "test-1", issueId: "INT-100", pr: null });
render(<SessionCard session={session} />);
expect(screen.getAllByText("INT-100")).toHaveLength(1);
expect(screen.getByText("no PR yet")).toBeInTheDocument();
});
it("shows icon-only terminate button in the footer", () => {
@ -546,14 +491,7 @@ describe("SessionCard", () => {
expect(screen.getByRole("button", { name: /terminate session/i })).toBeInTheDocument();
});
it("prevents duplicate quick-reply preset sends while a send is in flight", async () => {
let resolveSend: (() => void) | null = null;
const onSend = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveSend = resolve;
}),
);
it("does not render an inline quick-reply block on needs-input cards", () => {
const session = makeSession({
id: "respond-1",
status: "needs_input",
@ -561,34 +499,6 @@ describe("SessionCard", () => {
summary: "Need approval to proceed",
});
render(<SessionCard session={session} onSend={onSend} />);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
expect(onSend).toHaveBeenCalledTimes(1);
expect(onSend).toHaveBeenCalledWith("respond-1", "continue");
expect(screen.getByRole("button", { name: "Sending..." })).toBeDisabled();
expect(screen.getByRole("button", { name: "Abort" })).toBeDisabled();
expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Abort" }));
expect(onSend).toHaveBeenCalledTimes(1);
resolveSend?.();
await waitFor(() => {
expect(screen.getByRole("button", { name: "Sent" })).toBeInTheDocument();
});
});
it("hides quick reply presets for terminal sessions", () => {
const session = makeSession({
id: "respond-ended",
status: "terminated",
activity: "exited",
summary: "Need approval to proceed",
});
render(<SessionCard session={session} />);
expect(screen.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
@ -597,112 +507,23 @@ describe("SessionCard", () => {
expect(
screen.queryByRole("textbox", { name: /type a reply to the agent/i }),
).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: /view current context/i })).toHaveAttribute(
"href",
"/projects/my-app/sessions/respond-ended",
});
it("renders the terminal link on non-terminal cards but not on terminal ones", () => {
const active = makeSession({ id: "respond-active", activity: "waiting_input" });
const { rerender } = render(<SessionCard session={active} />);
expect(screen.getByText("terminal")).toBeInTheDocument();
rerender(
<SessionCard
session={makeSession({
id: "respond-ended",
status: "terminated",
activity: "exited",
})}
/>,
);
expect(screen.getByText("restore")).toBeInTheDocument();
});
it("prevents duplicate enter submits and only clears the textarea after send settles", async () => {
let resolveSend: (() => void) | null = null;
const onSend = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveSend = resolve;
}),
);
const session = makeSession({
id: "respond-2",
status: "needs_input",
activity: "waiting_input",
summary: "Need approval to proceed",
});
render(<SessionCard session={session} onSend={onSend} />);
const input = screen.getByRole("textbox", { name: /type a reply to the agent/i });
fireEvent.change(input, { target: { value: "please continue" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
expect(onSend).toHaveBeenCalledTimes(1);
expect(onSend).toHaveBeenCalledWith("respond-2", "please continue");
expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).toBeDisabled();
expect(screen.getByDisplayValue("please continue")).toBeInTheDocument();
fireEvent.keyDown(screen.getByRole("textbox", { name: /type a reply to the agent/i }), {
key: "Enter",
code: "Enter",
});
expect(onSend).toHaveBeenCalledTimes(1);
resolveSend?.();
await waitFor(() => {
expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).toHaveValue("");
});
});
it("does not show sent state or clear reply text when quick reply send fails", async () => {
const onSend = vi.fn(() => Promise.reject(new Error("network failed")));
const session = makeSession({
id: "respond-3",
status: "needs_input",
activity: "waiting_input",
summary: "Need approval to proceed",
});
render(<SessionCard session={session} onSend={onSend} />);
const input = screen.getByRole("textbox", { name: /type a reply to the agent/i });
fireEvent.change(input, { target: { value: "please continue" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
await waitFor(() => {
expect(onSend).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Sent" })).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).toHaveValue(
"please continue",
);
expect(
screen.getByRole("textbox", { name: /type a reply to the agent/i }),
).not.toBeDisabled();
});
});
it("shows a temporary failed state when an alert action send is rejected", async () => {
const onSend = vi.fn(() => Promise.reject(new Error("network failed")));
const pr = makePR({
state: "open",
ciStatus: "failing",
ciChecks: [{ name: "test", status: "failed" }],
reviewDecision: "approved",
mergeability: {
mergeable: false,
ciPassing: false,
approved: true,
noConflicts: true,
blockers: [],
},
});
const session = makeSession({ activity: "idle", pr });
render(<SessionCard session={session} onSend={onSend} />);
const actionButton = screen.getByRole("button", { name: "Ask to fix" });
fireEvent.click(actionButton);
await waitFor(() => {
expect(onSend).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "failed" })).toBeInTheDocument();
});
expect(screen.queryByRole("button", { name: "Sent!" })).not.toBeInTheDocument();
expect(screen.queryByText("terminal")).not.toBeInTheDocument();
});
});
@ -713,13 +534,13 @@ describe("AttentionZone", () => {
const sessions = [makeSession({ id: "s1" }), makeSession({ id: "s2" })];
render(<AttentionZone level="respond" sessions={sessions} />);
// Labels use CSS text-transform:uppercase but DOM text is title-cased
expect(screen.getByText("Respond")).toBeInTheDocument();
expect(screen.getByText("Needs you")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
});
it("renders empty state when sessions array is empty", () => {
render(<AttentionZone level="respond" sessions={[]} />);
expect(screen.getByText("Respond")).toBeInTheDocument();
expect(screen.getByText("Needs you")).toBeInTheDocument();
expect(screen.getByText("0")).toBeInTheDocument();
expect(screen.queryByText("No agents need your input.")).not.toBeInTheDocument();
});
@ -727,7 +548,7 @@ describe("AttentionZone", () => {
it("renders zone-specific empty messages for all attention zones", () => {
const cases: Array<[string, string]> = [
["review", "Review"],
["pending", "Pending"],
["pending", "In review"],
["working", "Working"],
["done", "Done"],
];
@ -751,8 +572,9 @@ describe("AttentionZone", () => {
it("working zone is collapsed by default", () => {
const sessions = [makeSession({ id: "s1" })];
render(<AttentionZone level="working" sessions={sessions} />);
// working is defaultCollapsed: false (Kanban always shows), so sessions visible
expect(screen.getByText("Working")).toBeInTheDocument();
// working is defaultCollapsed: false (Kanban always shows), so sessions visible.
// "Working" appears as both the column header and the card's StatusBadge.
expect(screen.getAllByText("Working").length).toBeGreaterThan(0);
});
it("done zone always shows sessions (kanban columns are always expanded)", () => {
@ -774,19 +596,21 @@ describe("AttentionZone", () => {
// ── Unenriched PR shimmer ─────────────────────────────────────────────
describe("Unenriched PR shimmer", () => {
it("SessionCard shows shimmer for unenriched PR size", () => {
it("SessionCard shows a loading footer detail for an unenriched PR (no inline size shimmer)", () => {
const pr = makePR({ enriched: false });
const session = makeSession({ pr });
const { container } = render(<SessionCard session={session} />);
const shimmers = container.querySelectorAll(".animate-pulse");
expect(shimmers.length).toBeGreaterThan(0);
// The slim informational card carries no inline PR-size chip/shimmer.
expect(container.querySelectorAll(".animate-pulse").length).toBe(0);
expect(screen.getByText("loading…")).toBeInTheDocument();
});
it("SessionCard shows actual size for enriched PR", () => {
it("SessionCard does not render an inline PR size on the card for an enriched PR", () => {
const pr = makePR({ enriched: true, additions: 50, deletions: 10 });
const session = makeSession({ pr });
render(<SessionCard session={session} />);
expect(screen.getByText("+50 -10 S")).toBeInTheDocument();
// PR size lives on the session page now, not the kanban card.
expect(screen.queryByText("+50 -10 S")).not.toBeInTheDocument();
});
it("SessionCard suppresses alerts for unenriched PR", () => {

View File

@ -219,6 +219,7 @@ export function makeSession(overrides: Partial<DashboardSession> = {}): Dashboar
...baseSession,
...overrides,
issueLabel: overrides.issueLabel ?? overrides.issueId ?? baseSession.issueLabel,
prs: overrides.prs ?? (overrides.pr ? [overrides.pr] : []),
} satisfies DashboardSession;
return {

View File

@ -0,0 +1,12 @@
/**
* Test stub for `next/font/local`. The real export is a build-time macro
* transformed by Next's compiler and is not callable under vitest, so we alias
* it here (see vitest.config.ts) to a plain function returning the same shape.
*/
export default function localFont(options: { variable?: string } = {}) {
return {
className: "mock-font",
variable: options.variable ?? "--font-mock",
style: { fontFamily: "mock" },
};
}

View File

@ -54,6 +54,7 @@ function makeSession(overrides: Partial<Session> & { id: string }): Session {
lastActivityAt: new Date("2025-01-01T00:00:00Z"),
metadata: {},
...overrides,
prs: overrides?.prs ?? (overrides?.pr ? [overrides.pr] : []),
};
}

View File

@ -12,6 +12,14 @@ export async function POST(_request: NextRequest, { params }: { params: Promise<
return jsonWithCorrelation({ error: "Invalid PR number" }, { status: 400 }, correlationId);
}
const prNumber = Number(id);
const owner = _request.nextUrl.searchParams.get("owner") ?? undefined;
const repo = _request.nextUrl.searchParams.get("repo") ?? undefined;
const prMatches = (p: { number: number; owner?: string; repo?: string }) =>
p.number === prNumber &&
(!owner || p.owner?.toLowerCase() === owner.toLowerCase()) &&
(!repo || p.repo?.toLowerCase() === repo.toLowerCase());
let configForObservation: OrchestratorConfig | undefined;
let projectId: string | undefined;
let sessionId: string | undefined;
@ -21,8 +29,14 @@ export async function POST(_request: NextRequest, { params }: { params: Promise<
configForObservation = config;
const sessions = await sessionManager.list();
const session = sessions.find((s) => s.pr?.number === prNumber);
if (!session?.pr) {
const session = sessions.find(
(s) => (s.pr && prMatches(s.pr)) || (s.prs ?? []).some(prMatches),
);
const targetPR =
session?.pr && prMatches(session.pr)
? session.pr
: (session?.prs ?? []).find(prMatches);
if (!session || !targetPR) {
return jsonWithCorrelation({ error: "PR not found" }, { status: 404 }, correlationId);
}
projectId = session.projectId;
@ -39,7 +53,7 @@ export async function POST(_request: NextRequest, { params }: { params: Promise<
}
// Validate PR is in a mergeable state
const state = await scm.getPRState(session.pr);
const state = await scm.getPRState(targetPR);
if (state !== "open") {
recordActivityEvent({
projectId: session.projectId,
@ -57,7 +71,7 @@ export async function POST(_request: NextRequest, { params }: { params: Promise<
);
}
const mergeability = await scm.getMergeability(session.pr);
const mergeability = await scm.getMergeability(targetPR);
if (!mergeability.mergeable) {
recordActivityEvent({
projectId: session.projectId,
@ -75,7 +89,7 @@ export async function POST(_request: NextRequest, { params }: { params: Promise<
);
}
await scm.mergePR(session.pr, "squash");
await scm.mergePR(targetPR, "squash");
recordApiObservation({
config,
method: "POST",

View File

@ -152,7 +152,8 @@ export async function GET(request: Request) {
// PR enrichment: read from session metadata (written by CLI lifecycle).
// No GitHub API calls — synchronous metadata read.
for (let i = 0; i < workerSessions.length; i++) {
if (!workerSessions[i]?.pr) continue;
const ws = workerSessions[i];
if (!ws?.pr && (!ws?.prs || ws.prs.length === 0)) continue;
enrichSessionPR(dashboardSessions[i]);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ describe("app layout metadata", () => {
expect(viewport.themeColor).toEqual([
{ media: "(prefers-color-scheme: light)", color: "#f5f3f0" },
{ media: "(prefers-color-scheme: dark)", color: "#121110" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0b0d" },
]);
});

View File

@ -3,7 +3,13 @@ import type { ReactNode } from "react";
import { getProjectName } from "@/lib/project-name";
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
import { Providers } from "@/app/providers";
import { schibstedGrotesk, jetbrainsMono } from "@/fonts/fonts";
import "./globals.css";
// Per-screen mission-control styles, loaded after globals.css so they win on
// equal specificity (see DESIGN.md). Split per screen to keep them focused.
import "./mc-sidebar.css";
import "./mc-board.css";
import "./mc-session.css";
export const viewport: Viewport = {
width: "device-width",
@ -11,7 +17,7 @@ export const viewport: Viewport = {
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f5f3f0" },
{ media: "(prefers-color-scheme: dark)", color: "#121110" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0b0d" },
],
};
@ -33,7 +39,11 @@ export async function generateMetadata(): Promise<Metadata> {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<html
lang="en"
className={`dark ${schibstedGrotesk.variable} ${jetbrainsMono.variable}`}
suppressHydrationWarning
>
<body className="h-screen overflow-hidden bg-[var(--color-bg-base)] text-[var(--color-text-primary)] antialiased">
<Providers>{children}</Providers>
<ServiceWorkerRegistrar />

View File

@ -0,0 +1,289 @@
/* Mission-control: mc-board screen styles. Loaded after globals.css (layout.tsx) so
these rules win on equal specificity. Tokens: var(--bg/--card/--t1..--t4/
--blue/--orange/--amber/--red/--green/--line/--line-2/--hover/--hover-2). */
/* Topbar (mockup .topbar)
Brand block now lives in the sidebar restyle the header as page tabs,
a "N working" pill, a notifications bell, and one blue primary action. */
.dashboard-app-header {
height: 56px;
gap: 13px;
padding: 0 20px;
}
/* Page tabs (mockup .tabs) — flat text tabs, not a boxed segmented switch. */
.dashboard-app-header .workspace-mode-switch {
margin-left: 6px;
gap: 2px;
border: none;
border-radius: 0;
background: transparent;
padding: 0;
}
.dashboard-app-header .workspace-mode-switch__item {
height: 28px;
padding: 0 11px;
border-radius: 6px;
font-size: 12.5px;
color: var(--t3);
}
.dashboard-app-header .workspace-mode-switch__item:hover {
background: transparent;
color: var(--t1);
}
.dashboard-app-header .workspace-mode-switch__item--active {
background: var(--hover-2);
color: var(--t1);
box-shadow: none;
}
/* Project label reads as a crumb to the left of the tabs. */
.dashboard-app-header__project {
font-size: 14.5px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--t1);
}
/* "N working" pill (mockup .pill) — hairline ring, breathing orange dot. */
.topbar-status-pill--active {
gap: 7px;
padding: 5px 11px;
border-radius: 6px;
background: transparent;
box-shadow: inset 0 0 0 1px var(--line);
color: var(--t2);
}
.topbar-status-pill--active .topbar-status-pill__dot--working {
animation: breathe 2.4s ease-in-out infinite;
}
/* Bell icon button (mockup .ibtn) — frameless ghost button. */
.dashboard-app-header .dashboard-notification-btn.dashboard-app-btn {
width: 34px;
height: 34px;
padding: 0;
border: none;
border-radius: 7px;
background: transparent;
color: var(--t2);
}
.dashboard-app-header .dashboard-notification-btn.dashboard-app-btn:hover {
background: var(--hover);
color: var(--t1);
}
/* Blue solid-fill primary action (mockup .btn-primary). */
.dashboard-app-header .dashboard-app-btn--primary {
height: 34px;
padding: 0 15px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
}
/* Board header (mockup .bhead)
"Board" 21px/700, tight tracking, with an inline subtitle. */
.dashboard-main__subhead {
display: flex;
align-items: baseline;
gap: 12px;
padding: 22px 18px 0;
}
.dashboard-main__title {
font-size: 21px;
font-weight: 700;
letter-spacing: -0.025em;
}
.dashboard-main__subtitle {
margin-top: 0;
font-size: 12.5px;
color: var(--t3);
}
/* Column heads (mockup .col-head)
Small dot with a semantic glow + uppercase tinted title (.08em) +
mono count pushed right. The trough/glow already live in globals. */
.kanban-column__header {
padding: 14px 15px 11px;
}
.kanban-column__title-row {
gap: 9px;
}
.kanban-column__dot {
width: 7px;
height: 7px;
}
.kanban-column__title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
font-family: var(--font-sans);
color: var(--ctc, var(--t2));
}
.kanban-column__count {
margin-left: auto;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
font-size: 11px;
line-height: 1;
color: var(--t3);
}
/* Per-column dot glow + tinted title, mirroring kanban.html .c-work/.c-attn/.c-rev/.c-ready */
.kanban-column[data-level="working"] {
--ctc: rgb(231, 180, 131);
}
.kanban-column[data-level="working"] .kanban-column__dot {
background: var(--orange);
box-shadow: 0 0 7px color-mix(in srgb, var(--orange) 60%, transparent);
}
.kanban-column[data-level="action"],
.kanban-column[data-level="respond"] {
--ctc: rgb(227, 207, 141);
}
.kanban-column[data-level="action"] .kanban-column__dot,
.kanban-column[data-level="respond"] .kanban-column__dot {
background: var(--amber);
box-shadow: 0 0 7px color-mix(in srgb, var(--amber) 60%, transparent);
}
.kanban-column[data-level="pending"],
.kanban-column[data-level="review"] {
--ctc: rgb(160, 170, 185);
}
.kanban-column[data-level="pending"] .kanban-column__dot,
.kanban-column[data-level="review"] .kanban-column__dot {
background: var(--t2);
}
.kanban-column[data-level="merge"] {
--ctc: rgb(150, 200, 168);
}
.kanban-column[data-level="merge"] .kanban-column__dot {
background: var(--green);
box-shadow: 0 0 7px color-mix(in srgb, var(--green) 60%, transparent);
}
/* ── Column body — gaps/padding to match the mockup .col-body ─────── */
.kanban-column-body {
padding: 0 11px 12px;
}
.kanban-column__stack {
gap: 10px;
}
/* Card (mockup .k)
Header: StatusBadge · spacer · mono id · hover-only "terminal" link.
Title 13px/500 2-line clamp. Branch its own mono line. Footer thin
top-bordered info row. Most of this lives in globals; here we set the
compact paddings and the hover-revealed terminal link (mockup .k-term). */
.session-card__header {
gap: 8px;
padding: 12px 13px 9px;
}
.card__title-wrap {
padding: 0 13px 10px;
}
.card__meta {
padding: 0 13px 10px;
}
.session-card__footer {
gap: 10px;
padding: 9px 13px 11px;
}
/* Hover-revealed terminal link (mockup .k-term) — faint mono, no chrome. */
.session-card__terminal-link {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
padding: 2px 6px;
border: none;
border-radius: 5px;
background: transparent;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 400;
color: var(--t4);
opacity: 0;
transition:
opacity 0.14s ease,
background 0.14s ease,
color 0.14s ease;
}
.session-card:hover .session-card__terminal-link,
.session-card:focus-within .session-card__terminal-link {
opacity: 1;
}
.session-card__terminal-link:hover {
background: var(--hover-2);
color: var(--t2);
text-decoration: none;
}
.session-card__terminal-link-icon {
width: 11px;
height: 11px;
flex-shrink: 0;
}
/* ── Done / Terminated row (mockup .donerow) ─────────────────────────── */
.done-bar__toggle {
padding: 11px 4px 16px;
color: var(--t3);
font-size: 12px;
}
.done-bar__count {
font-family: var(--font-mono);
font-size: 11px;
color: var(--t4);
}
/* Empty-state ghost columns match the live frameless tinted troughs
(override globals.css: 4 columns, no dashed/warm border, per the new board.) */
.kanban-ghost {
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.kanban-ghost__col {
border: none;
border-radius: 13px;
background: rgba(255, 255, 255, 0.018);
opacity: 1;
}
.dark .kanban-ghost__col {
border-color: transparent;
}
.kanban-ghost__head {
padding: 14px 15px 11px;
border-bottom: none;
letter-spacing: 0.08em;
color: var(--t3, #646a73);
}
.dark .kanban-ghost__head {
border-bottom-color: transparent;
}

View File

@ -0,0 +1,301 @@
/* Mission-control: mc-session screen styles. Loaded after globals.css (layout.tsx) so
these rules win on equal specificity. Tokens: var(--bg/--card/--t1..--t4/
--blue/--blue-soft/--orange/--amber/--red/--green/--line/--line-2/--hover/--hover-2). */
/* Topbar (session context) matches mockup .topbar
Brand removed (it lives in the sidebar). Order: Kanban · vdiv · title +
branch · status pill · spacer · bell · Kill · Orchestrator. */
.session-topbar.dashboard-app-header {
height: 56px;
gap: 13px;
padding: 0 16px 0 14px;
}
/* Kanban back button */
.session-board-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px 7px 9px;
border-radius: 7px;
font-size: 14px;
font-weight: 600;
color: var(--t2);
text-decoration: none;
flex-shrink: 0;
transition:
background 0.12s ease,
color 0.12s ease;
}
.session-board-btn:hover {
background: var(--hover);
color: var(--t1);
text-decoration: none;
}
.session-board-btn svg {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.session-topbar__vdiv {
width: 1px;
height: 22px;
margin: 0 3px;
background: var(--line);
flex-shrink: 0;
}
/* Session identity — title first, branch to its right (mockup .sess-id) */
.session-topbar__id {
display: flex;
flex-direction: row;
align-items: center;
gap: 11px;
min-width: 0;
}
.session-topbar__title {
font-size: 13.5px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--t1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 360px;
flex-shrink: 1;
}
.session-topbar__branch {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
max-width: 240px;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--t3);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-topbar__branch--link:hover {
color: var(--blue-soft);
text-decoration: none;
}
.session-topbar__branch-icon {
display: inline-flex;
align-items: center;
color: var(--t4);
flex-shrink: 0;
}
/* Kill (danger) + Orchestrator (primary) sized to the mockup .btn */
.session-topbar .dashboard-app-header__actions .dashboard-app-btn {
height: 33px;
padding: 0 14px;
border-radius: 7px;
font-size: 12.5px;
font-weight: 600;
}
/* The notifications bell is an icon-only square (mockup .ibtn) the text-button
padding above would otherwise squeeze its icon to a sliver. */
.session-topbar .dashboard-app-header__actions .dashboard-notification-btn.dashboard-app-btn {
width: 34px;
height: 34px;
padding: 0;
}
.session-topbar .dashboard-app-btn--danger {
border: 1px solid color-mix(in srgb, var(--red) 28%, transparent);
color: #ff8a8a;
}
.session-topbar .dashboard-app-btn--danger:hover {
background: color-mix(in srgb, var(--red) 10%, transparent);
color: #ff8a8a;
}
.session-topbar .dashboard-app-btn--primary {
background: var(--blue);
color: #fff;
}
.session-topbar .dashboard-app-btn--primary:hover {
background: #5b97ff;
color: #fff;
}
/* Framed terminal flush to sidebar + topbar (mockup .terminal)
Only the term-head carries a bottom border; remove the outer frame border
and the stray left/right/top seam the live terminal otherwise paints. */
.session-workspace__main {
background: var(--term);
}
.session-workspace__main > .relative.h-full,
.session-workspace__main > .h-full {
border: 0;
background: var(--term);
}
/* term-head: 47px, aligned to the inspector tab bar. No connection text. */
.terminal-chrome-bar {
height: 47px;
flex-shrink: 0;
padding: 0 15px;
background: var(--bg);
border-bottom: 1px solid var(--line);
}
.terminal-chrome-pane-label {
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--t3);
padding: 0;
margin: 0;
border-right: 0;
}
.terminal-chrome-session-id {
font-family: var(--font-mono);
font-size: 11px;
color: var(--t2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
/* Zoom group ( 13px +) */
.terminal-chrome-zoom {
gap: 8px;
color: var(--t3);
font-family: var(--font-mono);
font-size: 11px;
}
.terminal-chrome-zoom__btn {
width: 22px;
height: 22px;
display: grid;
place-items: center;
border-radius: 5px;
color: var(--t3);
transition:
background 0.12s ease,
color 0.12s ease;
}
.terminal-chrome-zoom__btn:hover:not(:disabled) {
background: var(--hover-2);
color: var(--t1);
}
.terminal-chrome-zoom__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.terminal-chrome-zoom__value {
min-width: 30px;
text-align: center;
color: var(--t2);
}
/* Fullscreen icon button — icon only (mockup), label kept for a11y */
.terminal-chrome-fs-btn {
width: 22px;
height: 22px;
margin-left: 6px;
display: grid;
place-items: center;
border-radius: 5px;
color: var(--t3);
transition:
background 0.12s ease,
color 0.12s ease;
}
.terminal-chrome-fs-btn:hover {
background: var(--hover-2);
color: var(--t1);
}
.terminal-chrome-fs-btn__label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Inspector rail (mockup .insp / .rail-tabs / .rail-body)
Most base styles already live in globals.css; restate the density values
here so they win and stay in sync with the mockup. */
.session-inspector {
background: var(--bg);
border-left: 1px solid var(--line);
}
.session-inspector__tabs {
height: 47px;
}
/* Activity timeline (mockup .tline) */
.inspector-timeline {
position: relative;
padding-left: 20px;
}
.inspector-timeline::before {
content: "";
position: absolute;
left: 5px;
top: 4px;
bottom: 6px;
width: 1px;
background: var(--line);
}
.inspector-timeline__ev {
position: relative;
padding: 0 0 15px;
}
.inspector-timeline__ev:last-child {
padding-bottom: 0;
}
.inspector-timeline__node {
position: absolute;
left: -18px;
top: 3px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--t4);
box-shadow: 0 0 0 3px var(--bg);
}
.inspector-timeline__ev--now .inspector-timeline__node {
background: var(--orange);
box-shadow:
0 0 0 3px var(--bg),
0 0 8px var(--orange);
}
.inspector-timeline__ev--good .inspector-timeline__node {
background: var(--green);
}
.inspector-timeline__ev--warn .inspector-timeline__node {
background: var(--amber);
}
.inspector-timeline__et {
font-size: 12px;
color: var(--t1);
line-height: 1.45;
}
.inspector-timeline__et b {
font-weight: 600;
}
.inspector-timeline__badge {
display: inline-flex;
vertical-align: middle;
}
.inspector-timeline__detail {
color: var(--t2);
}
.inspector-timeline__ets {
font-size: 10.5px;
color: var(--t4);
font-family: var(--font-mono);
margin-top: 2px;
}

View File

@ -0,0 +1,432 @@
/* Mission-control: mc-sidebar screen styles. Loaded after globals.css (layout.tsx) so
these rules win on equal specificity. Tokens: var(--bg/--card/--t1..--t4/
--blue/--orange/--amber/--red/--green/--line/--line-2/--hover/--hover-2). */
/* Root: force the cool near-black sidebar surface
globals.css gives .project-sidebar a warm tint (#0e0d0c + warm border).
Override it here to the cool mockup value and a neutral hairline. */
.project-sidebar {
--sidebar-bg: var(--color-bg-sidebar, #08090b);
--sidebar-border: var(--line, rgba(255, 255, 255, 0.06));
--sidebar-row-hover: var(--hover, rgba(255, 255, 255, 0.04));
--sidebar-row-active: var(--hover-2, rgba(255, 255, 255, 0.07));
background: var(--bg-side, #08090b);
border-right: 1px solid var(--line, rgba(255, 255, 255, 0.06));
width: 100%;
padding: 14px 7px;
}
.dark .project-sidebar {
--sidebar-bg: var(--color-bg-sidebar, #08090b);
--sidebar-border: var(--line, rgba(255, 255, 255, 0.06));
--sidebar-row-hover: var(--hover, rgba(255, 255, 255, 0.04));
--sidebar-row-active: var(--hover-2, rgba(255, 255, 255, 0.07));
background: var(--bg-side, #08090b);
}
.project-sidebar--collapsed {
padding: 10px 0;
}
/* ── Brand row ───────────────────────────────────────────────────────── */
.project-sidebar__brand {
display: flex;
align-items: center;
gap: 10px;
padding: 2px 8px 18px;
flex-shrink: 0;
}
.project-sidebar__brand-name {
font-weight: 700;
font-size: 14px;
letter-spacing: -0.015em;
color: var(--t1, #f4f5f7);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.project-sidebar__brand-sep {
font-weight: 400;
color: var(--t3, #646a73);
}
.project-sidebar__collapse-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
border: 0;
background: transparent;
color: var(--t3, #646a73);
cursor: pointer;
transition:
background 0.12s ease,
color 0.12s ease;
}
.project-sidebar__collapse-btn:hover {
background: var(--hover, rgba(255, 255, 255, 0.04));
color: var(--t1, #f4f5f7);
}
/* ── Section label ("Projects" + add) ────────────────────────────────── */
.project-sidebar__nav-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--t4, #444951);
padding: 0 8px 8px;
flex-shrink: 0;
}
.project-sidebar__add-btn {
width: 18px;
height: 18px;
border-radius: 4px;
color: var(--t4, #444951);
background: transparent;
}
.project-sidebar__add-btn:hover {
background: var(--hover, rgba(255, 255, 255, 0.04));
color: var(--t2, #9ba1aa);
}
.project-sidebar__add-btn svg {
width: 13px;
height: 13px;
}
/* ── Tree / project rows ─────────────────────────────────────────────── */
.project-sidebar__tree {
padding: 0;
}
.project-sidebar__project {
margin-bottom: 1px;
border-bottom: none;
}
.project-sidebar__proj-row {
position: relative;
border-radius: 5px;
}
.project-sidebar__proj-row:hover {
background: var(--hover, rgba(255, 255, 255, 0.04));
}
.project-sidebar__proj-toggle {
display: flex;
align-items: center;
gap: 9px;
flex: 1;
min-width: 0;
padding: 7px 6px;
background: transparent;
border-radius: 5px;
transition: padding-right 0.12s ease;
}
/* On hover the action icons reveal over the right slot reserve room so the
project name re-truncates (ellipsis) instead of running under the icons. */
.project-sidebar__proj-row:hover .project-sidebar__proj-toggle,
.project-sidebar__proj-row:focus-within .project-sidebar__proj-toggle {
padding-right: 78px;
}
.project-sidebar__proj-toggle:hover {
background: transparent;
}
.project-sidebar__proj-toggle--active,
.project-sidebar__proj-row:has(.project-sidebar__proj-toggle--active) {
background: var(--hover-2, rgba(255, 255, 255, 0.07));
}
.project-sidebar__proj-chevron {
width: 9px;
height: 9px;
color: var(--t4, #444951);
flex-shrink: 0;
}
.project-sidebar__proj-name {
font-size: 13px;
font-weight: 500;
color: var(--t2, #9ba1aa);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-sidebar__proj-toggle--active .project-sidebar__proj-name {
color: var(--t1, #f4f5f7);
font-weight: 600;
}
/* Plain mono count shows at rest in the same right slot the action icons
reveal into on hover, so the project name keeps full width at rest. */
.project-sidebar__proj-count {
font-size: 11px;
color: var(--t3, #646a73);
font-family: var(--mono, var(--font-mono));
flex-shrink: 0;
transition: opacity 0.12s ease;
}
.project-sidebar__proj-row:hover .project-sidebar__proj-count,
.project-sidebar__proj-row:focus-within .project-sidebar__proj-count {
opacity: 0;
}
/* ── Per-project action icons — out of flow, revealed on hover ────────── */
.project-sidebar__proj-actions {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 1px;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
}
.project-sidebar__proj-row:hover .project-sidebar__proj-actions,
.project-sidebar__proj-row:focus-within .project-sidebar__proj-actions {
opacity: 1;
pointer-events: auto;
}
.project-sidebar__proj-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 5px;
color: var(--t3, #646a73);
transition:
background 0.12s ease,
color 0.12s ease;
}
.project-sidebar__proj-action:hover {
background: var(--hover-2, rgba(255, 255, 255, 0.07));
color: var(--t1, #f4f5f7);
}
.project-sidebar__proj-action--menu[aria-expanded="true"] {
background: var(--hover-2, rgba(255, 255, 255, 0.07));
color: var(--t1, #f4f5f7);
}
/* ── Sessions list ───────────────────────────────────────────────────── */
.project-sidebar__sessions {
padding: 2px 0 8px 4px;
margin-left: 5px;
border-left: 1px solid var(--line, rgba(255, 255, 255, 0.06));
}
.project-sidebar__sess-row {
position: relative;
display: flex;
align-items: center;
gap: 9px;
padding: 5px 6px 5px 8px;
border-radius: 5px;
}
.project-sidebar__sess-row:hover {
background: var(--hover, rgba(255, 255, 255, 0.04));
}
.project-sidebar__sess-row--active {
background: var(--hover-2, rgba(255, 255, 255, 0.07));
border-left: 0;
padding-left: 8px;
}
.project-sidebar__sess-label {
display: block;
font-size: 12px;
font-family: var(--mono, var(--font-mono));
color: var(--t2, #9ba1aa);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* At rest the link spans the row so the label gets full width (with ellipsis).
On hover the rename pencil reveals as an absolute overlay, so we reserve room
for it by padding the link the text re-truncates instead of being covered. */
.project-sidebar__sess-link {
transition: padding-right 0.12s ease;
}
.project-sidebar__sess-row:hover .project-sidebar__sess-link,
.project-sidebar__sess-row:focus-within .project-sidebar__sess-link {
padding-right: 22px;
}
.project-sidebar__sess-row--active .project-sidebar__sess-label,
.project-sidebar__sess-label--active {
color: var(--t1, #f4f5f7);
font-weight: 400;
}
.project-sidebar__sess-meta {
margin-top: 0;
}
.project-sidebar__sess-id {
font-size: 10px;
color: var(--t4, #444951);
font-family: var(--mono, var(--font-mono));
}
/* ── Session status dots ─────────────────────────────────────────────── */
.sidebar-session-dot {
width: 6px;
height: 6px;
/* Nudge down ~1px: mono text sits slightly low in its line box, so a
geometrically-centered dot reads high next to the label. */
margin-top: 1px;
background: var(--t4, #444951);
}
.sidebar-session-dot[data-level="working"] {
background: var(--orange, #f59f4c);
animation: mc-breathe 2.4s ease-in-out infinite;
}
.sidebar-session-dot--glow {
box-shadow: none;
}
@keyframes mc-breathe {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
/* Rename pencil — hover-only, neutral. */
.project-sidebar__sess-rename-btn {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--t4, #444951);
border-radius: 5px;
}
.project-sidebar__sess-rename-btn:hover {
background: var(--hover-2, rgba(255, 255, 255, 0.07));
color: var(--t1, #f4f5f7);
}
/* ── Footer: single Settings button + popover ────────────────────────── */
.project-sidebar__footer {
border-top: 1px solid var(--line, rgba(255, 255, 255, 0.06));
margin-top: auto;
}
.project-sidebar__foot-inner {
display: flex;
align-items: center;
padding: 12px 0 0;
}
.project-sidebar__foot-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 6px;
border: 0;
background: transparent;
color: var(--t3, #646a73);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
background 0.12s ease,
color 0.12s ease;
}
.project-sidebar__foot-btn:hover,
.project-sidebar__foot-btn--active {
background: var(--hover, rgba(255, 255, 255, 0.04));
color: var(--t1, #f4f5f7);
}
.project-sidebar__foot-label {
letter-spacing: -0.01em;
}
.project-sidebar__settings-wrap {
position: relative;
}
.project-sidebar__settings-popover {
bottom: calc(100% + 8px);
left: 0;
right: auto;
min-width: 200px;
padding: 6px;
border-radius: 8px;
border: 1px solid var(--line-2, rgba(255, 255, 255, 0.1));
background: var(--card, #15171b);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
gap: 2px;
}
.project-sidebar__settings-row {
padding: 7px 8px;
border-radius: 5px;
font-size: 12.5px;
color: var(--t2, #9ba1aa);
}
.project-sidebar__settings-row:hover {
background: var(--hover, rgba(255, 255, 255, 0.04));
}
.project-sidebar__settings-row--toggle {
justify-content: space-between;
cursor: default;
}
.project-sidebar__settings-row--toggle:hover {
background: transparent;
}
.project-sidebar__settings-row input[type="checkbox"] {
accent-color: var(--blue, #4d8dff);
}

View File

@ -258,9 +258,6 @@ function SessionPageShell({
</svg>
)}
</button>
<div className="dashboard-app-header__brand">
<span>Agent Orchestrator</span>
</div>
<div className="dashboard-app-header__spacer" />
</header>

View File

@ -0,0 +1,21 @@
import Image from "next/image";
/**
* The AO brand mark the Claude Code mascot recolored blue (the conductor).
* A small black tile with a faint blue glow; the blue/orange split is the
* product's identity (blue = the orchestrator / you).
*/
export function AppMark() {
return (
<span className="app-mark" aria-hidden="true">
<Image
src="/mascot.png"
alt=""
width={23}
height={23}
className="app-mark__img"
priority
/>
</span>
);
}

View File

@ -9,11 +9,9 @@ import { projectSessionPath } from "@/lib/routes";
interface AttentionZoneProps {
level: AttentionLevel;
sessions: DashboardSession[];
onSend?: (sessionId: string, message: string) => Promise<void> | void;
onKill?: (sessionId: string) => void;
onMerge?: (prNumber: number) => void;
onMerge?: (prNumber: number, owner?: string, repo?: string) => void;
onRestore?: (sessionId: string) => void;
onReview?: (sessionId: string) => Promise<void> | void;
/** Accordion mode: whether this section is collapsed (mobile only) */
collapsed?: boolean;
/** Accordion mode: called when the header is tapped to toggle */
@ -34,15 +32,15 @@ const zoneConfig: Record<
}
> = {
merge: {
label: "Ready",
label: "Ready to merge",
emptyMessage: "Nothing cleared to land yet.",
},
action: {
label: "Action",
label: "Needs you",
emptyMessage: "No agents need your input.",
},
respond: {
label: "Respond",
label: "Needs you",
emptyMessage: "No agents need your input.",
},
review: {
@ -50,7 +48,7 @@ const zoneConfig: Record<
emptyMessage: "No code waiting for review.",
},
pending: {
label: "Pending",
label: "In review",
emptyMessage: "Nothing blocked.",
},
working: {
@ -75,11 +73,9 @@ const zoneConfig: Record<
function AttentionZoneView({
level,
sessions,
onSend,
onKill,
onMerge,
onRestore,
onReview,
collapsed,
onToggle,
compactMobile,
@ -139,11 +135,9 @@ function AttentionZoneView({
<SessionCard
key={session.id}
session={session}
onSend={onSend}
onKill={onKill}
onMerge={onMerge}
onRestore={onRestore}
onReview={onReview}
/>
),
)}
@ -184,11 +178,9 @@ function AttentionZoneView({
<SessionCard
key={session.id}
session={session}
onSend={onSend}
onKill={onKill}
onMerge={onMerge}
onRestore={onRestore}
onReview={onReview}
/>
))}
</div>
@ -203,11 +195,9 @@ function areAttentionZonePropsEqual(prev: AttentionZoneProps, next: AttentionZon
prev.level === next.level &&
prev.collapsed === next.collapsed &&
prev.onToggle === next.onToggle &&
prev.onSend === next.onSend &&
prev.onKill === next.onKill &&
prev.onMerge === next.onMerge &&
prev.onRestore === next.onRestore &&
prev.onReview === next.onReview &&
prev.compactMobile === next.compactMobile &&
prev.onPreview === next.onPreview &&
prev.resetKey === next.resetKey &&
@ -299,10 +289,11 @@ export function getActionChipLabel(session: DashboardSession): string {
// Review-class: status
if (session.status === "ci_failed") return "ci failed";
if (session.status === "changes_requested") return "changes";
// Review-class: PR signals
if (session.pr?.ciStatus === "failing") return "ci failed";
if (session.pr?.reviewDecision === "changes_requested") return "changes";
if (session.pr && !session.pr.mergeability.noConflicts) return "conflicts";
// Review-class: PR signals — aggregate across all PRs
const prs = session.prs.length > 0 ? session.prs : (session.pr ? [session.pr] : []);
if (prs.some((p) => p.ciStatus === "failing")) return "ci failed";
if (prs.some((p) => p.reviewDecision === "changes_requested")) return "changes";
if (prs.some((p) => !p.mergeability.noConflicts)) return "conflicts";
return "action";
}
@ -315,16 +306,17 @@ function SessionStateChip({
}) {
let label = zoneConfig[level].label.toLowerCase();
if (level === "merge" && session.pr && isPRMergeReady(session.pr)) {
const prs = session.prs.length > 0 ? session.prs : (session.pr ? [session.pr] : []);
if (level === "merge" && prs.length > 0 && prs.every((p) => isPRMergeReady(p))) {
label = "ready";
} else if (level === "action") {
label = getActionChipLabel(session);
} else if (level === "respond") {
label = session.activity === "waiting_input" ? "waiting" : "needs input";
} else if (level === "review") {
label = session.pr?.reviewDecision === "changes_requested" ? "changes" : "review";
label = prs.some((p) => p.reviewDecision === "changes_requested") ? "changes" : "review";
} else if (level === "pending") {
label = session.pr?.unresolvedThreads ? "threads" : "pending";
label = prs.some((p) => p.unresolvedThreads) ? "threads" : "pending";
} else if (level === "working") {
label = session.activity === "idle" ? "idle" : "active";
}

View File

@ -45,7 +45,7 @@ interface BottomSheetProps {
onConfirm: () => void;
onCancel: () => void;
onRequestKill?: () => void;
onMerge?: (prNumber: number) => void;
onMerge?: (prNumber: number, owner?: string, repo?: string) => void;
isMergeReady?: boolean;
}
@ -292,7 +292,7 @@ export function BottomSheet({
className="bottom-sheet__btn bottom-sheet__btn--secondary"
onClick={() => {
if (mergePrNumber !== null) {
onMerge(mergePrNumber);
onMerge(mergePrNumber, session.pr?.owner, session.pr?.repo);
}
}}
>

View File

@ -43,8 +43,8 @@ interface DashboardProps {
dashboardLoadError?: string;
}
const SIMPLE_KANBAN_LEVELS = ["working", "pending", "action", "merge"] as const;
const DETAILED_KANBAN_LEVELS = ["working", "pending", "review", "respond", "merge"] as const;
const SIMPLE_KANBAN_LEVELS = ["working", "action", "pending", "merge"] as const;
const DETAILED_KANBAN_LEVELS = ["working", "respond", "review", "pending", "merge"] as const;
const EMPTY_ORCHESTRATORS: DashboardOrchestratorLink[] = [];
function formatRelativeTimeCompact(isoDate: string | null): string {
@ -343,38 +343,6 @@ function DashboardInner({
});
}, [activeOrchestrators, allProjectsView, attentionZones, projects, sessionsByProject]);
const handleSend = useCallback(
async (sessionId: string, message: string) => {
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
if (!res.ok) {
const text = await res.text();
const messageText = text || "Unknown error";
console.error(`Failed to send message to ${sessionId}:`, messageText);
showToast(`Send failed: ${messageText}`, "error");
const errorWithToast = new Error(messageText);
(errorWithToast as Error & { toastShown?: boolean }).toastShown = true;
throw errorWithToast;
}
} catch (error) {
const toastShown =
error instanceof Error &&
"toastShown" in error &&
(error as Error & { toastShown?: boolean }).toastShown;
if (!toastShown) {
console.error(`Network error sending message to ${sessionId}:`, error);
showToast("Network error while sending message", "error");
}
throw error;
}
},
[showToast],
);
const killSession = useCallback(
async (sessionId: string) => {
try {
@ -425,9 +393,13 @@ function DashboardInner({
}, [previewSession, killSession]);
const handleMerge = useCallback(
async (prNumber: number) => {
async (prNumber: number, owner?: string, repo?: string) => {
try {
const res = await fetch(`/api/prs/${prNumber}/merge`, { method: "POST" });
const params = new URLSearchParams();
if (owner) params.set("owner", owner);
if (repo) params.set("repo", repo);
const qs = params.size > 0 ? `?${params.toString()}` : "";
const res = await fetch(`/api/prs/${prNumber}/merge${qs}`, { method: "POST" });
if (!res.ok) {
const text = await res.text();
console.error(`Failed to merge PR #${prNumber}:`, text);
@ -466,32 +438,6 @@ function DashboardInner({
[showToast],
);
const handleRequestReview = useCallback(
async (sessionId: string) => {
try {
const res = await fetch("/api/reviews", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId }),
});
const data = (await res.json().catch(() => null)) as { error?: string } | null;
if (!res.ok) {
throw new Error(data?.error ?? "Failed to request review");
}
const session = sessionsRef.current.find((entry) => entry.id === sessionId);
showToast("Review run requested", "success");
routerRef.current.push(projectReviewPath(session?.projectId ?? projectId));
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to request review";
console.error(`Failed to request review for ${sessionId}:`, error);
showToast(`Review failed: ${message}`, "error");
throw error;
}
},
[projectId, showToast],
);
const handleSpawnOrchestrator = async (project: ProjectInfo) => {
setSpawningProjectIds((current) =>
current.includes(project.id) ? current : [...current, project.id],
@ -554,11 +500,10 @@ function DashboardInner({
() => sessions.some((session) => session.pr && isPRRateLimited(session.pr)),
[sessions],
);
const normalizedProjectName = projectName?.trim().toLowerCase();
// Always show the human-readable project name in the titlebar — never the raw
// project id hash (that was a poor disambiguation hack and reads as gibberish).
const headerProjectLabel =
normalizedProjectName === "agent orchestrator"
? (projectId ?? projectName ?? (allProjectsView ? "All projects" : "Dashboard"))
: (projectName ?? (allProjectsView ? "All projects" : "Dashboard"));
projectName ?? (allProjectsView ? "All projects" : "Dashboard");
const showHeaderProjectLabel = !allProjectsView && headerProjectLabel.trim().length > 0;
const handleZoneToggle = (level: AttentionLevel) => {
@ -576,13 +521,15 @@ function DashboardInner({
const mainPanel = (
<div className="dashboard-main--desktop">
<header className="dashboard-app-header">
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={handleToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
{/* Mobile-only drawer toggle. On desktop the sidebar owns its own
collapse/expand affordance, so the topbar doesn't duplicate it. */}
{isMobile ? (
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={handleToggleSidebar}
aria-label="Toggle sidebar"
>
<svg
width="16"
height="16"
@ -594,24 +541,8 @@ function DashboardInner({
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
) : (
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
)}
</button>
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
<span>Agent Orchestrator</span>
</div>
</button>
) : null}
{showHeaderProjectLabel ? (
<>
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
@ -669,7 +600,7 @@ function DashboardInner({
{!allProjectsView && orchestratorHref ? (
<Link
href={orchestratorHref}
className="dashboard-app-btn dashboard-app-btn--amber"
className="dashboard-app-btn dashboard-app-btn--primary"
aria-label="Orchestrator"
>
<svg
@ -692,7 +623,7 @@ function DashboardInner({
) : canSpawnProjectOrchestrator && activeProject ? (
<button
type="button"
className="dashboard-app-btn dashboard-app-btn--amber"
className="dashboard-app-btn dashboard-app-btn--primary"
aria-label="Spawn Orchestrator"
onClick={() => void handleSpawnOrchestrator(activeProject)}
disabled={isSpawningCurrentProject}
@ -721,9 +652,9 @@ function DashboardInner({
<main className="dashboard-main flex flex-col flex-1 min-h-0 overflow-hidden">
<DynamicFavicon attentionLevels={attentionLevels} projectName={projectName} />
<div className="dashboard-main__subhead">
<h1 className="dashboard-main__title">Dashboard</h1>
<h1 className="dashboard-main__title">Board</h1>
<p className="dashboard-main__subtitle">
Live agent sessions, pull requests, and merge status.
Live agent sessions flowing from work review merge.
</p>
</div>
@ -789,11 +720,8 @@ function DashboardInner({
key={level}
level={level}
sessions={grouped[level]}
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
onReview={handleRequestReview}
compactMobile={isMobile}
collapsed={isMobile && collapsedZones.has(level)}
onToggle={isMobile ? handleZoneToggle : undefined}

View File

@ -406,11 +406,11 @@ export function DashboardNotificationButton() {
onClick={() => setOpen((value) => !value)}
>
<svg
width="13"
height="13"
width="17"
height="17"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeWidth="1.6"
viewBox="0 0 24 24"
aria-hidden="true"
>

View File

@ -9,8 +9,10 @@ import { getAttentionLevel, type DashboardSession } from "@/lib/types";
import { isOrchestratorSession } from "@aoagents/ao-core/types";
import { getSessionTitle, humanizeBranch } from "@/lib/format";
import { usePopoverClamp } from "@/hooks/usePopoverClamp";
import { projectDashboardPath, projectReviewPath, projectSessionPath } from "@/lib/routes";
import { useResizable } from "@/hooks/useResizable";
import { projectDashboardPath, projectSessionPath } from "@/lib/routes";
import { ThemeToggle } from "./ThemeToggle";
import { AppMark } from "./AppMark";
import { AddProjectModal } from "./AddProjectModal";
import { ProjectSettingsModal } from "./ProjectSettingsModal";
@ -106,6 +108,46 @@ function loadExpandedProjects(): Set<string> | null {
}
/**
* Brand row at the top of the sidebar: blue mascot mark + wordmark with the
* " / " separator dimmed. Mirrors the mockup `.brand`. When a toggle handler is
* supplied, a collapse affordance (panel-left icon) sits at the right edge.
*/
function SidebarBrand({ onToggleCollapsed }: { onToggleCollapsed?: () => void }) {
return (
<div className="project-sidebar__brand">
<AppMark />
<span className="project-sidebar__brand-name">
Agent<b className="project-sidebar__brand-sep"> / </b>Orchestrator
</span>
{onToggleCollapsed ? (
<button
type="button"
className="project-sidebar__collapse-btn"
onClick={onToggleCollapsed}
title="Collapse sidebar"
aria-label="Collapse sidebar"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M9 4v16" />
</svg>
</button>
) : null}
</div>
);
}
export function ProjectSidebar(props: ProjectSidebarProps) {
if (props.projects.length === 0) {
return <ProjectSidebarEmpty collapsed={props.collapsed} />;
@ -238,8 +280,9 @@ function ProjectSidebarEmpty({ collapsed = false }: { collapsed?: boolean }) {
return (
<aside className="project-sidebar flex h-full flex-col">
<div className="project-sidebar__compact-hdr">
<span className="project-sidebar__sect-label">Projects</span>
<SidebarBrand />
<div className="project-sidebar__nav-label">
<span>Projects</span>
<button
type="button"
className="project-sidebar__add-btn"
@ -250,7 +293,8 @@ function ProjectSidebarEmpty({ collapsed = false }: { collapsed?: boolean }) {
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeWidth="1.8"
strokeLinecap="round"
viewBox="0 0 24 24"
>
<path d="M12 5v14M5 12h14" />
@ -261,7 +305,7 @@ function ProjectSidebarEmpty({ collapsed = false }: { collapsed?: boolean }) {
No projects yet. Click + to add one.
</div>
<div className="project-sidebar__footer">
<div className="flex items-center justify-end gap-1 border-t border-[var(--color-border-subtle)] px-2 py-2">
<div className="project-sidebar__foot-inner">
<ThemeToggle className="project-sidebar__theme-toggle" />
</div>
</div>
@ -280,11 +324,19 @@ function ProjectSidebarInner({
error = false,
onRetry,
collapsed = false,
onToggleCollapsed: _onToggleCollapsed,
onToggleCollapsed,
onMobileClose,
}: ProjectSidebarProps) {
const router = useRouter();
const _isLoading = loading || sessions === null;
const { onPointerDown: onResizePointerDown, onDoubleClick: onResizeDoubleClick } = useResizable({
cssVar: "--ao-sidebar-w",
storageKey: "ao-sidebar-w",
defaultWidth: 240,
min: 200,
max: 420,
edge: "right",
});
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
() =>
@ -612,6 +664,29 @@ function ProjectSidebarInner({
"project-sidebar project-sidebar--collapsed flex flex-col h-full items-center py-2 gap-1 overflow-y-auto",
)}
>
{onToggleCollapsed ? (
<button
type="button"
className="project-sidebar__collapse-btn project-sidebar__expand-btn"
onClick={onToggleCollapsed}
aria-label="Expand sidebar"
title="Expand sidebar"
>
<svg
width="17"
height="17"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="4" width="18" height="16" rx="2.5" />
<line x1="9" y1="4" x2="9" y2="20" />
<path d="m13 9 3 3-3 3" />
</svg>
</button>
) : null}
{visibleProjects.map((project, idx) => {
const workerSessions = sessionsByProject.get(project.id) ?? [];
// sessionsByProject already applies the showDone filter consistently.
@ -675,9 +750,10 @@ function ProjectSidebarInner({
}
return (
<aside className="project-sidebar flex h-full flex-col">
<div className="project-sidebar__compact-hdr">
<span className="project-sidebar__sect-label">Projects</span>
<aside className="project-sidebar relative flex h-full flex-col">
<SidebarBrand onToggleCollapsed={onToggleCollapsed} />
<div className="project-sidebar__nav-label">
<span>Projects</span>
<button
type="button"
className="project-sidebar__add-btn"
@ -688,7 +764,8 @@ function ProjectSidebarInner({
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeWidth="1.8"
strokeLinecap="round"
viewBox="0 0 24 24"
>
<path d="M12 5v14M5 12h14" />
@ -736,7 +813,6 @@ function ProjectSidebarInner({
const projectHref = projectDashboardPath(project.id);
// sessionsByProject already applies the showDone filter consistently.
const visibleSessions = workerSessions;
const hasActiveSessions = visibleSessions.length > 0;
const orchestratorLink = orchestratorByProject.get(project.id) ?? null;
// Look up the full session object so navigate() can cache it in
// sessionStorage — prevents the "Session unavailable" flash on
@ -809,17 +885,15 @@ function ProjectSidebarInner({
<path d="m9 18 6-6-6-6" />
</svg>
<span className="project-sidebar__proj-name">{project.name}</span>
<span
className={cn(
"project-sidebar__proj-badge",
hasActiveSessions && "project-sidebar__proj-badge--active",
)}
>
<span className="project-sidebar__proj-count">
{sessionsByProject.get(project.id)?.length ?? 0}
</span>
</button>
)}
{/* Row actions absolutely positioned over the count slot; the
count shows at rest, these reveal on hover (frees name space). */}
<div className="project-sidebar__proj-actions">
{/* Dashboard button */}
{!isDegraded ? (
<Link
@ -846,31 +920,6 @@ function ProjectSidebarInner({
</Link>
) : null}
{!isDegraded ? (
<Link
href={projectReviewPath(project.id)}
onClick={(e) => {
e.stopPropagation();
onMobileClose?.();
}}
className="project-sidebar__proj-action"
aria-label={`Open ${project.name} reviews`}
title="Reviews"
>
<svg
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M9 11l2 2 4-4" />
<path d="M5 4h14v16H5z" />
</svg>
</Link>
) : null}
{!isDegraded && orchestratorLink && (
<a
href={projectSessionPath(project.id, orchestratorLink.id)}
@ -980,6 +1029,7 @@ function ProjectSidebarInner({
</div>
) : null}
</div>
</div>
</div>
{isDegraded ? (
@ -1077,94 +1127,60 @@ function ProjectSidebarInner({
})}
</div>
<div className="project-sidebar__footer">
<div className="flex items-center gap-1 border-t border-[var(--color-border-subtle)] px-2 py-2">
{/* Show killed toggle */}
<button
type="button"
onClick={() => setShowKilled(!showKilled)}
className={cn(
"project-sidebar__footer-btn",
showKilled && "project-sidebar__footer-btn--active",
)}
aria-pressed={showKilled}
title={showKilled ? "Hide killed sessions" : "Show killed sessions"}
aria-label={showKilled ? "Hide killed sessions" : "Show killed sessions"}
>
{/* skull / terminated icon */}
<svg
width="13"
height="13"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M12 3C7.03 3 3 7.03 3 12c0 3.1 1.5 5.84 3.8 7.55V21h2.4v-1h1.6v1h2.4v-1h1.6v1H17v-1.45A9 9 0 0 0 21 12c0-4.97-4.03-9-9-9z" />
<circle cx="9" cy="11" r="1.5" fill="currentColor" stroke="none" />
<circle cx="15" cy="11" r="1.5" fill="currentColor" stroke="none" />
</svg>
</button>
{/* Show done toggle */}
<button
type="button"
onClick={() => setShowDone(!showDone)}
className={cn(
"project-sidebar__footer-btn",
showDone && "project-sidebar__footer-btn--active",
)}
aria-pressed={showDone}
title={showDone ? "Hide completed sessions" : "Show completed sessions"}
aria-label={showDone ? "Hide completed sessions" : "Show completed sessions"}
>
{/* checkmark / done icon */}
<svg
width="13"
height="13"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</button>
<div className="flex-1" />
{/* Sidebar display settings (gear) */}
<div className="project-sidebar__foot-inner">
{/* Single Settings gear — opens a popover holding all display toggles. */}
<div className="project-sidebar__settings-wrap" ref={settingsRef}>
<button
type="button"
onClick={() => setSettingsOpen((v) => !v)}
className={cn(
"project-sidebar__footer-btn",
settingsOpen && "project-sidebar__footer-btn--active",
"project-sidebar__foot-btn",
settingsOpen && "project-sidebar__foot-btn--active",
)}
aria-expanded={settingsOpen}
aria-haspopup="dialog"
title="Sidebar settings"
aria-label="Sidebar settings"
title="Settings"
aria-label="Settings"
>
<svg
width="13"
height="13"
width="15"
height="15"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
<span className="project-sidebar__foot-label">Settings</span>
</button>
{settingsOpen ? (
<div
ref={settingsPopoverRef}
className="project-sidebar__settings-popover"
role="dialog"
aria-label="Sidebar settings"
aria-label="Settings"
>
<label className="project-sidebar__settings-row">
<input
type="checkbox"
checked={showKilled}
onChange={(e) => setShowKilled(e.target.checked)}
/>
<span>Show killed sessions</span>
</label>
<label className="project-sidebar__settings-row">
<input
type="checkbox"
checked={showDone}
onChange={(e) => setShowDone(e.target.checked)}
/>
<span>Show done sessions</span>
</label>
<label className="project-sidebar__settings-row">
<input
type="checkbox"
@ -1173,12 +1189,22 @@ function ProjectSidebarInner({
/>
<span>Show session ID</span>
</label>
<div className="project-sidebar__settings-row project-sidebar__settings-row--toggle">
<span>Theme</span>
<ThemeToggle className="project-sidebar__theme-toggle" />
</div>
</div>
) : null}
</div>
<ThemeToggle className="project-sidebar__theme-toggle" />
</div>
</div>
{!collapsed ? (
<div
className="resize-handle resize-handle--right"
onPointerDown={onResizePointerDown}
onDoubleClick={onResizeDoubleClick}
/>
) : null}
<AddProjectModal open={addProjectOpen} onClose={() => setAddProjectOpen(false)} />
<ProjectSettingsModal
open={projectSettingsProjectId !== null}

View File

@ -0,0 +1,336 @@
"use client";
import { useState } from "react";
import {
type DashboardSession,
isPRRateLimited,
isPRUnenriched,
getSessionTruthLabel,
isDashboardSessionRestorable,
} from "@/lib/types";
import { cn } from "@/lib/cn";
import { getSessionTitle } from "@/lib/format";
import { CICheckList } from "./CIBadge";
import { getSizeLabel } from "./PRStatus";
import { projectSessionPath } from "@/lib/routes";
/**
* Determine the status display info for done cards.
*/
function getDoneStatusInfo(session: DashboardSession): {
label: string;
pillClass: string;
icon: React.ReactNode;
} {
const activity = session.activity;
const status = session.status;
const prState = session.lifecycle?.prState ?? session.pr?.state;
if (prState === "merged" || status === "merged") {
return {
label: "merged",
pillClass: "done-status-pill--merged",
icon: (
<svg
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
className="h-3 w-3"
>
<path d="M20 6 9 17l-5-5" />
</svg>
),
};
}
if (prState === "closed") {
return {
label: "closed",
pillClass: "done-status-pill--exited",
icon: (
<svg
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
className="h-3 w-3"
>
<circle cx="12" cy="12" r="9" />
<path d="M9 12h6" />
</svg>
),
};
}
if (
session.lifecycle?.sessionState === "terminated" ||
status === "killed" ||
status === "terminated"
) {
return {
label: getSessionTruthLabel(session),
pillClass: "done-status-pill--killed",
icon: (
<svg
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
className="h-3 w-3"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
),
};
}
// Default: exited / done / cleanup / closed PR
const label = activity === "exited" ? "exited" : getSessionTruthLabel(session);
return {
label,
pillClass: "done-status-pill--exited",
icon: (
<svg
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
className="h-3 w-3"
>
<circle cx="12" cy="12" r="9" />
<path d="M9 12h6" />
</svg>
),
};
}
interface DoneSessionCardProps {
session: DashboardSession;
onRestore?: (sessionId: string) => void;
}
/**
* Done / Terminated card variant kept intact from the original SessionCard.
* Click to expand a detail panel (summary, issue, CI checks, PR metrics).
*/
export function DoneSessionCard({ session, onRestore }: DoneSessionCardProps) {
const [expanded, setExpanded] = useState(false);
const pr = session.pr;
const rateLimited = pr ? isPRRateLimited(pr) : false;
const prUnenriched = pr ? isPRUnenriched(pr) : false;
const isRestorable = isDashboardSessionRestorable(session);
const title = getSessionTitle(session);
const statusInfo = getDoneStatusInfo(session);
return (
<div
className={cn("session-card-done", expanded && "done-expanded")}
onClick={(e) => {
if ((e.target as HTMLElement).closest("a, button, textarea")) return;
setExpanded(!expanded);
}}
>
{/* Row 1: Status pill + session id + restore */}
<div className="flex items-center gap-2 px-3.5 pt-3 pb-1.5">
<span className={cn("done-status-pill", statusInfo.pillClass)}>
{statusInfo.icon}
{statusInfo.label}
</span>
<span className="font-[var(--font-mono)] text-[10px] tracking-wide text-[var(--color-text-muted)]">
{session.id}
</span>
<div className="flex-1" />
{isRestorable && (
<button
onClick={(e) => {
e.stopPropagation();
onRestore?.(session.id);
}}
className="done-card__restore"
>
<svg
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
className="h-3 w-3"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
restore
</button>
)}
</div>
{/* Row 2: Title */}
<div className="px-3.5 pb-2">
<p className="session-card-done__title text-[13px] font-semibold leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:2] overflow-hidden">
{title}
</p>
</div>
{/* Row 3: Meta chips */}
<div className="flex flex-wrap items-center gap-1.5 px-3.5 pb-3">
{session.branch && (
<span className="done-meta-chip font-[var(--font-mono)]">
<svg
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
className="h-2.5 w-2.5 opacity-50"
>
<path d="M6 3v12M18 9a3 3 0 0 1-3 3H9a3 3 0 0 0-3 3" />
<circle cx="18" cy="6" r="3" />
</svg>
{session.branch}
</span>
)}
{pr && (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="done-meta-chip font-[var(--font-mono)] font-bold text-[var(--color-text-primary)] no-underline underline-offset-2 hover:underline"
>
#{pr.number}
</a>
)}
{pr &&
!rateLimited &&
(prUnenriched ? (
<span className="inline-block h-[14px] w-16 animate-pulse rounded-full bg-[var(--color-bg-subtle)]" />
) : (
<span className="done-meta-chip font-[var(--font-mono)]">
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>{" "}
{getSizeLabel(pr.additions, pr.deletions)}
<span className="sr-only">
{`+${pr.additions} -${pr.deletions} ${getSizeLabel(pr.additions, pr.deletions)}`}
</span>
</span>
))}
<a
href={projectSessionPath(session.projectId, session.id)}
onClick={(e) => e.stopPropagation()}
className="done-meta-chip font-[var(--font-mono)] font-semibold text-[var(--color-accent)] no-underline hover:underline"
>
View current context
</a>
</div>
{/* Expandable detail panel */}
{expanded && (
<div className="done-expand-section px-3.5 py-3">
{session.summary && pr?.title && session.summary !== pr.title && (
<div className="mb-3">
<div className="done-detail-heading">
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M4 6h16M4 12h16M4 18h10" />
</svg>
Summary
</div>
<p className="text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
{session.summary}
</p>
</div>
)}
{session.issueUrl && (
<div className="mb-3">
<div className="done-detail-heading">
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
Issue
</div>
<a
href={session.issueUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-[12px] text-[var(--color-accent)] hover:underline"
>
{session.issueLabel || session.issueUrl}
{session.issueTitle && `: ${session.issueTitle}`}
</a>
</div>
)}
{pr && pr.ciChecks.length > 0 && (
<div className="mb-3">
<div className="done-detail-heading">
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
CI Checks
</div>
<CICheckList checks={pr.ciChecks} />
</div>
)}
{pr && (
<div className="mb-3">
<div className="done-detail-heading">
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65S8.93 17.38 9 18v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>
PR
</div>
<p className="text-[12px] text-[var(--color-text-secondary)]">
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="hover:underline"
>
{pr.title}
</a>
{prUnenriched ? (
<>
<br />
<span className="mt-1 inline-flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<span className="inline-block h-3 w-12 animate-pulse rounded bg-[var(--color-bg-subtle)]" />
<span>PR details loading...</span>
</span>
</>
) : (
<>
<br />
<span className="mt-1 inline-flex items-center gap-2">
<span className="done-meta-chip font-[var(--font-mono)]">
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>
</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="text-[10px] text-[var(--color-text-muted)]">
mergeable: {pr.mergeability.mergeable ? "yes" : "no"}
</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="text-[10px] text-[var(--color-text-muted)]">
review: {pr.reviewDecision}
</span>
</span>
</>
)}
</p>
</div>
)}
{!pr && (
<p className="text-[12px] text-[var(--color-text-tertiary)]">
No PR associated with this session.
</p>
)}
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@ import { projectDashboardPath, projectSessionPath } from "@/lib/routes";
import { MobileBottomNav } from "./MobileBottomNav";
import { SessionDetailHeader, type OrchestratorZones } from "./SessionDetailHeader";
import { SessionEndedSummary } from "./SessionEndedSummary";
import { sessionActivityMeta } from "./session-detail-utils";
import { SessionInspector } from "./SessionInspector";
export type { OrchestratorZones } from "./SessionDetailHeader";
@ -54,13 +54,12 @@ export function SessionDetail({
const sidebarCtx = useSidebarContext();
const startFullscreen = searchParams.get("fullscreen") === "true";
const [showTerminal, setShowTerminal] = useState(false);
const pr = session.pr;
const [selectedPRIndex, setSelectedPRIndex] = useState(0);
const prs = session.prs ?? [];
const safeSelectedPRIndex = Math.min(selectedPRIndex, Math.max(0, prs.length - 1));
const pr = prs[safeSelectedPRIndex] ?? session.pr;
const terminalEnded = isDashboardSessionTerminal(session);
const isRestorable = isDashboardSessionRestorable(session);
const activity = (session.activity && sessionActivityMeta[session.activity]) ?? {
label: session.activity ?? "unknown",
color: "var(--color-text-muted)",
};
const headline = getSessionTitle(session);
const terminalVariant = isOrchestrator ? "orchestrator" : "agent";
@ -113,6 +112,10 @@ export function SessionDetail({
return null;
}, [isOrchestrator, projectOrchestratorId, session.projectId]);
useEffect(() => {
setSelectedPRIndex(0);
}, [session.id]);
useEffect(() => {
const frame = window.requestAnimationFrame(() => setShowTerminal(true));
return () => {
@ -129,17 +132,18 @@ export function SessionDetail({
isMobile={isMobile}
terminalEnded={terminalEnded}
isRestorable={isRestorable}
activity={activity}
headline={headline}
projects={projects}
orchestratorHref={orchestratorHref}
orchestratorZones={orchestratorZones}
selectedPRIndex={safeSelectedPRIndex}
onSelectPR={setSelectedPRIndex}
onToggleSidebar={sidebarCtx?.onToggleSidebar ?? (() => {})}
onRestore={handleRestore}
onKill={handleKill}
/>
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
<div className="flex-1 min-h-0 flex flex-col">
<main className="session-detail-page session-workspace flex-1 min-h-0 flex bg-[var(--color-bg-base)]">
<div className="session-workspace__main flex-1 min-h-0 flex flex-col">
{!showTerminal ? (
<div className="session-detail-terminal-placeholder h-full" />
) : terminalEnded ? (
@ -166,6 +170,11 @@ export function SessionDetail({
/>
)}
</div>
{/* The orchestrator session has no PR/changes/browser to inspect give
it the full-width terminal (no inspector rail). */}
{!isMobile && !terminalEnded && !isOrchestrator ? (
<SessionInspector session={session} />
) : null}
</main>
<MobileBottomNav
ariaLabel="Session navigation"

View File

@ -0,0 +1,135 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { CI_STATUS } from "@aoagents/ao-core/types";
import { cn } from "@/lib/cn";
import { type DashboardSession, type DashboardPR, isPRMergeReady } from "@/lib/types";
import { SessionDetailPRCard } from "./SessionDetailPRCard";
import { askAgentToFix } from "./session-detail-agent-actions";
import type { OrchestratorZones } from "./SessionDetailHeader";
export function GitBranchIcon() {
return (
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="6" y1="4" x2="6" y2="14" />
<circle cx="6" cy="17" r="2.3" />
<circle cx="18" cy="7" r="2.3" />
<path d="M18 9.3a8 8 0 0 1-8 8" />
</svg>
);
}
export function OrchestratorZonePills({ zones }: { zones: OrchestratorZones }) {
const stats: Array<{ value: number; label: string; toneClass: string }> = [
{ value: zones.merge, label: "merge", toneClass: "topbar-zone-pill--merge" },
{ value: zones.respond, label: "respond", toneClass: "topbar-zone-pill--respond" },
{ value: zones.review, label: "review", toneClass: "topbar-zone-pill--review" },
{ value: zones.working, label: "working", toneClass: "topbar-zone-pill--working" },
{ value: zones.pending, label: "pending", toneClass: "topbar-zone-pill--pending" },
{ value: zones.done, label: "done", toneClass: "topbar-zone-pill--done" },
].filter((s) => s.value > 0);
if (stats.length === 0) return null;
return (
<span className="topbar-fleet-pills" aria-label="Fleet session counts">
<span className="topbar-fleet-pills__label">Fleet</span>
{stats.map((s) => (
<span key={s.label} className={cn("topbar-zone-pill", s.toneClass)}>
<span className="topbar-zone-pill__value">{s.value}</span>
<span className="topbar-zone-pill__label">{s.label}</span>
</span>
))}
</span>
);
}
/**
* Mobile-only PR affordance. On desktop the PR card lives in the inspector
* rail; on mobile (no rail) this popover surfaces the same SessionDetailPRCard.
*/
export function MobilePrButton({ session, pr }: { session: DashboardSession; pr: DashboardPR }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const allGreen = isPRMergeReady(pr);
useEffect(() => {
if (!open) return;
const handler = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
const keyHandler = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", keyHandler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", keyHandler);
};
}, [open]);
return (
<div className="topbar-pr-btn-wrap" ref={ref}>
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className={cn("dashboard-app-btn topbar-pr-btn", open && "topbar-pr-btn--open")}
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
event.preventDefault();
setOpen((value) => !value);
}}
aria-expanded={open}
aria-label={`PR #${pr.number}`}
>
<span
className={cn(
"topbar-pr-dot",
allGreen
? "topbar-pr-dot--green"
: pr.ciStatus === CI_STATUS.FAILING || pr.reviewDecision === "changes_requested"
? "topbar-pr-dot--red"
: "topbar-pr-dot--amber",
)}
/>
PR #{pr.number}
<svg
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d={open ? "M18 15l-6-6-6 6" : "M6 9l6 6 6-6"} />
</svg>
</a>
{open && (
<div className="topbar-pr-popover">
<SessionDetailPRCard
pr={pr}
metadata={session.metadata}
lifecyclePrReason={session.lifecycle?.prReason ?? undefined}
onAskAgentToFix={(comment, onSuccess, onError) =>
askAgentToFix(session.id, comment, onSuccess, onError)
}
/>
</div>
)}
</div>
);
}

View File

@ -8,8 +8,10 @@ import type { ProjectInfo } from "@/lib/project-name";
import { DashboardNotificationButton } from "./DashboardNotificationButton";
import { SessionDetailPRCard } from "./SessionDetailPRCard";
import { askAgentToFix } from "./session-detail-agent-actions";
import { StatusBadge } from "./StatusBadge";
import { buildGitHubBranchUrl } from "./session-detail-utils";
import { projectDashboardPath } from "@/lib/routes";
import { GitBranchIcon, OrchestratorZonePills } from "./SessionDetailHeader.parts";
export interface OrchestratorZones {
merge: number;
@ -26,61 +28,36 @@ interface SessionDetailHeaderProps {
isMobile: boolean;
terminalEnded: boolean;
isRestorable: boolean;
activity: { label: string; color: string };
headline: string;
projects: ProjectInfo[];
orchestratorHref: string | null;
orchestratorZones?: OrchestratorZones;
selectedPRIndex: number;
onSelectPR: (index: number) => void;
onToggleSidebar: () => void;
onRestore: () => void;
onKill: () => void;
}
function normalizeActivityLabelForClass(activityLabel: string): string {
return activityLabel.toLowerCase().replace(/\s+/g, "-");
}
function OrchestratorZonePills({ zones }: { zones: OrchestratorZones }) {
const stats: Array<{ value: number; label: string; toneClass: string }> = [
{ value: zones.merge, label: "merge", toneClass: "topbar-zone-pill--merge" },
{ value: zones.respond, label: "respond", toneClass: "topbar-zone-pill--respond" },
{ value: zones.review, label: "review", toneClass: "topbar-zone-pill--review" },
{ value: zones.working, label: "working", toneClass: "topbar-zone-pill--working" },
{ value: zones.pending, label: "pending", toneClass: "topbar-zone-pill--pending" },
{ value: zones.done, label: "done", toneClass: "topbar-zone-pill--done" },
].filter((s) => s.value > 0);
if (stats.length === 0) return null;
return (
<span className="topbar-fleet-pills" aria-label="Fleet session counts">
<span className="topbar-fleet-pills__label">Fleet</span>
{stats.map((s) => (
<span key={s.label} className={cn("topbar-zone-pill", s.toneClass)}>
<span className="topbar-zone-pill__value">{s.value}</span>
<span className="topbar-zone-pill__label">{s.label}</span>
</span>
))}
</span>
);
}
export function SessionDetailHeader({
session,
isOrchestrator,
isMobile,
terminalEnded,
isRestorable,
activity,
headline,
projects,
orchestratorHref,
orchestratorZones,
selectedPRIndex,
onSelectPR,
onToggleSidebar,
onRestore,
onKill,
}: SessionDetailHeaderProps) {
const pr = session.pr;
const prs = session.prs ?? [];
const safeSelectedPRIndex = Math.min(selectedPRIndex, Math.max(0, prs.length - 1));
const pr = prs[safeSelectedPRIndex] ?? session.pr;
const allGreen = pr ? isPRMergeReady(pr) : false;
const [prPopoverOpen, setPrPopoverOpen] = useState(false);
const prPopoverRef = useRef<HTMLDivElement>(null);
@ -105,68 +82,67 @@ export function SessionDetailHeader({
const headerProjectLabel =
projects.find((project) => project.id === session.projectId)?.name ?? session.projectId;
const showHeaderProjectLabel = headerProjectLabel.trim().toLowerCase() !== "agent orchestrator";
const showProductBrand = !isOrchestrator;
const showProjectLabel = isOrchestrator || showHeaderProjectLabel;
const showDesktopTitle = !isOrchestrator;
const showDesktopHeaderSep = showProductBrand && showProjectLabel;
return (
<header className="dashboard-app-header">
{projects.length > 0 ? (
<header className="dashboard-app-header session-topbar">
{/* Mobile-only drawer toggle. On desktop the sidebar carries its own
collapse/expand affordance, so the topbar doesn't duplicate it. */}
{isMobile && projects.length > 0 ? (
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={onToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
) : (
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
)}
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
) : null}
{showProductBrand ? (
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
<span>Agent Orchestrator</span>
</div>
{/* Kanban back button the project board. Workers get a plain "Kanban"
back; orchestrators keep their dedicated "Open Kanban"/fleet button
further along the row. */}
{!isOrchestrator ? (
<a
className="session-board-btn"
href={projectDashboardPath(session.projectId)}
title="Back to Kanban"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M15 18l-6-6 6-6" />
</svg>
<span className="topbar-btn-label">Kanban</span>
</a>
) : null}
{showDesktopHeaderSep && (
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
)}
<div className="topbar-project-pills-group">
<div className="topbar-project-line">
{showProjectLabel && (
{!isOrchestrator ? <span className="session-topbar__vdiv" aria-hidden="true" /> : null}
{isOrchestrator ? (
<div className="topbar-project-pills-group">
<div className="topbar-project-line">
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
)}
{showProjectLabel && isOrchestrator ? (
<span className="topbar-identity-sep" aria-hidden="true">
·
</span>
) : null}
{isOrchestrator ? (
<span className="session-detail-mode-badge session-detail-mode-badge--neutral">
<svg
width="12"
@ -185,53 +161,48 @@ export function SessionDetailHeader({
</svg>
Orchestrator
</span>
) : null}
{!isOrchestrator ? (
<span className="dashboard-app-header__session-id topbar-mobile-only">
{session.id}
</span>
) : null}
</div>
<div className="topbar-session-pills">
<div
className={cn(
"topbar-status-pill",
`topbar-status-pill--${normalizeActivityLabelForClass(activity.label)}`,
)}
>
<span className="topbar-status-pill__dot" />
<span className="topbar-status-pill__label">{activity.label}</span>
</div>
{!isOrchestrator && session.branch ? (
pr ? (
<a
href={buildGitHubBranchUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="topbar-branch-pill topbar-branch-pill--link"
>
{session.branch}
</a>
) : (
<span className="topbar-branch-pill">{session.branch}</span>
)
) : null}
{isOrchestrator && orchestratorZones ? (
<OrchestratorZonePills zones={orchestratorZones} />
) : null}
<div className="topbar-session-pills">
<StatusBadge session={session} variant="pill" />
{orchestratorZones ? <OrchestratorZonePills zones={orchestratorZones} /> : null}
</div>
</div>
</div>
{showDesktopTitle ? (
) : (
<>
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
<span className="dashboard-app-header__session-title topbar-desktop-only">
{headline}
</span>
{/* Session identity — TITLE first, BRANCH to its right (mono + git icon). */}
<div className="session-topbar__id">
<span className="session-topbar__title" title={headline}>
{headline}
</span>
{session.branch ? (
pr ? (
<a
href={buildGitHubBranchUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="session-topbar__branch session-topbar__branch--link"
title={session.branch}
>
<span className="session-topbar__branch-icon">
<GitBranchIcon />
</span>
{session.branch}
</a>
) : (
<span className="session-topbar__branch" title={session.branch}>
<span className="session-topbar__branch-icon">
<GitBranchIcon />
</span>
{session.branch}
</span>
)
) : null}
</div>
<StatusBadge session={session} variant="pill" />
<span className="dashboard-app-header__session-id topbar-mobile-only">{session.id}</span>
</>
) : null}
{!isOrchestrator ? (
<span className="dashboard-app-header__session-id topbar-desktop-only">{session.id}</span>
) : null}
)}
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
<DashboardNotificationButton />
@ -279,6 +250,36 @@ export function SessionDetailHeader({
{prPopoverOpen && (
<div className="topbar-pr-popover">
{prs.length > 1 && (
<div className="flex gap-0.5 px-3 pt-2 pb-1.5 border-b border-[var(--color-border-subtle)]">
{prs.map((p, i) => (
<button
key={p.url}
type="button"
onClick={() => onSelectPR(i)}
className={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded text-xs",
safeSelectedPRIndex === i
? "bg-[var(--color-bg-subtle)] text-[var(--color-text-primary)]"
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]",
)}
>
<span
className={cn(
"topbar-pr-dot",
isPRMergeReady(p)
? "topbar-pr-dot--green"
: p.ciStatus === CI_STATUS.FAILING ||
p.reviewDecision === "changes_requested"
? "topbar-pr-dot--red"
: "topbar-pr-dot--amber",
)}
/>
PR #{p.number}
</button>
))}
</div>
)}
<SessionDetailPRCard
pr={pr as DashboardPR}
metadata={session.metadata}
@ -322,10 +323,16 @@ export function SessionDetailHeader({
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
>
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M4 7h16" />
<path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
<path d="M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13" />
<line x1="10" y1="11.5" x2="10" y2="17.5" />
<line x1="14" y1="11.5" x2="14" y2="17.5" />
</svg>
<span className="topbar-btn-label">Kill</span>
</button>
@ -334,7 +341,7 @@ export function SessionDetailHeader({
{orchestratorHref ? (
<a
href={orchestratorHref}
className="dashboard-app-btn dashboard-app-btn--amber topbar-desktop-only"
className="dashboard-app-btn dashboard-app-btn--primary topbar-desktop-only"
aria-label="Orchestrator"
>
<svg

View File

@ -55,15 +55,27 @@ export function SessionEndedSummary({
session.lifecycle?.session.lastTransitionAt ??
session.lastActivityAt;
const runtimeLabel = session.lifecycle?.runtime.label ?? "Unavailable";
const prLabel = pr
? pr.state === "merged"
? "Merged"
: pr.state === "closed"
? "Closed"
: pr.mergeability.mergeable
? "Open, merge-ready"
: "Open"
: "No PR";
const prs = session.prs ?? [];
const primaryPR = pr ?? prs[0];
const allMerged = prs.length > 0 && prs.every((p) => p.state === "merged");
const anyOpen = prs.some((p) => p.state === "open");
const prLabel =
prs.length > 1
? allMerged
? "All merged"
: anyOpen
? "In progress"
: "Closed"
: primaryPR
? primaryPR.state === "merged"
? "Merged"
: primaryPR.state === "closed"
? "Closed"
: primaryPR.mergeability.mergeable
? "Open, merge-ready"
: "Open"
: "No PR";
const prCount = prs.length;
return (
<section className="session-ended-summary" aria-label="Session ended summary">
@ -105,7 +117,7 @@ export function SessionEndedSummary({
<strong>{runtimeLabel}</strong>
</div>
<div className="session-ended-summary__fact">
<span>PR</span>
<span>{prCount > 1 ? `PRs (${prCount})` : "PR"}</span>
<strong>{prLabel}</strong>
</div>
</div>
@ -120,7 +132,23 @@ export function SessionEndedSummary({
Restore session
</button>
) : null}
{pr ? (
{prs.length > 0 ? (
prs.map((p) => (
<a
key={p.url}
href={p.url}
target="_blank"
rel="noopener noreferrer"
className={
isRestorable
? "session-ended-summary__secondary"
: "session-ended-summary__primary"
}
>
Open PR #{p.number}
</a>
))
) : pr ? (
<a
href={pr.url}
target="_blank"

View File

@ -0,0 +1,311 @@
"use client";
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/cn";
import type { DashboardSession } from "@/lib/types";
import { useResizable } from "@/hooks/useResizable";
import { StatusBadge } from "./StatusBadge";
import { SessionDetailPRCard } from "./SessionDetailPRCard";
import { askAgentToFix } from "./session-detail-agent-actions";
import { formatTimeCompact } from "./session-detail-utils";
type InspectorView = "summary" | "changes" | "browser";
interface SessionInspectorProps {
session: DashboardSession;
}
const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [
{
id: "summary",
label: "Summary",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<line x1="8" y1="7" x2="20" y2="7" />
<line x1="8" y1="12" x2="20" y2="12" />
<line x1="8" y1="17" x2="16" y2="17" />
<circle cx="4" cy="7" r="1" />
<circle cx="4" cy="12" r="1" />
<circle cx="4" cy="17" r="1" />
</svg>
),
},
{
id: "changes",
label: "Changes",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<path d="M12 3v6" />
<path d="M9 6h6" />
<path d="M11 18H7a2 2 0 0 1-2-2V6" />
<path d="M13 15h4" />
<path d="M19 9v7a2 2 0 0 1-2 2h-2" />
</svg>
),
},
{
id: "browser",
label: "Browser",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<line x1="3" y1="12" x2="21" y2="12" />
<path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" />
</svg>
),
},
];
/**
* Pluggable inspector rail beside the framed terminal. Each view is a
* registered entry (Summary · Changes · Browser); adding more (Logs, Cost)
* is just another VIEWS entry + a branch in renderView. The Summary view is
* ordered by supervision value: Pull request Review comments Activity
* Overview (the PR card bundles the first two, incl. the soft-blue Address
* action that hands a comment + file:line to the agent).
*/
export function SessionInspector({ session }: SessionInspectorProps) {
const [view, setView] = useState<InspectorView>("summary");
const { onPointerDown, onDoubleClick } = useResizable({
cssVar: "--ao-inspector-w",
storageKey: "ao-inspector-w",
defaultWidth: 344,
min: 280,
max: 560,
edge: "left",
});
return (
<aside className="session-inspector" aria-label="Session inspector">
<div
className="resize-handle resize-handle--left"
onPointerDown={onPointerDown}
onDoubleClick={onDoubleClick}
role="separator"
aria-orientation="vertical"
aria-label="Resize inspector"
/>
<div className="session-inspector__tabs" role="tablist">
{VIEWS.map((entry) => (
<button
key={entry.id}
type="button"
role="tab"
aria-selected={view === entry.id}
className={cn("session-inspector__tab", view === entry.id && "is-active")}
onClick={() => setView(entry.id)}
>
<span className="session-inspector__tab-icon">{entry.icon}</span>
<span className="session-inspector__tab-label">{entry.label}</span>
</button>
))}
</div>
<div className="session-inspector__body">
{view === "summary" ? <SummaryView session={session} /> : null}
{view === "changes" ? <ChangesView session={session} /> : null}
{view === "browser" ? <BrowserView /> : null}
</div>
</aside>
);
}
function Section({
title,
action,
children,
}: {
title: string;
action?: ReactNode;
children: ReactNode;
}) {
return (
<section className="inspector-section">
<div className="inspector-section__head">
<span>{title}</span>
{action ?? null}
</div>
{children}
</section>
);
}
function SummaryView({ session }: { session: DashboardSession }) {
const pr = session.pr;
return (
<div role="tabpanel">
<Section
title="Pull request"
action={
pr ? (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="inspector-section__link"
>
Open
</a>
) : undefined
}
>
{pr ? (
<SessionDetailPRCard
pr={pr}
metadata={session.metadata}
lifecyclePrReason={session.lifecycle?.prReason ?? undefined}
onAskAgentToFix={(comment, onSuccess, onError) =>
askAgentToFix(session.id, comment, onSuccess, onError)
}
/>
) : (
<p className="inspector-empty">No pull request opened yet.</p>
)}
</Section>
<Section title="Activity">
<ActivityTimeline session={session} />
</Section>
<Section title="Overview">
<dl className="inspector-kv">
{session.metadata["agent"] ? <Row k="Agent" v={session.metadata["agent"]} mono /> : null}
{session.branch ? <Row k="Branch" v={session.branch} mono /> : null}
{session.issueLabel ? <Row k="Issue" v={session.issueLabel} mono /> : null}
<Row k="Started" v={formatTimeCompact(session.createdAt)} mono />
<Row k="Session" v={session.id} mono />
</dl>
</Section>
</div>
);
}
type TimelineTone = "now" | "good" | "warn" | "neutral";
/**
* Honest activity timeline only events we can derive from the session object:
* the live status (now), the PR (good), the last-active beat, and worktree
* creation (oldest). No fabricated commit/CI rows.
*/
function ActivityTimeline({ session }: { session: DashboardSession }) {
const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = [];
events.push({
tone: "now",
node: (
<>
<span className="inspector-timeline__badge">
<StatusBadge session={session} variant="pill" />
</span>
{session.lifecycle?.summary ? (
<span className="inspector-timeline__detail"> {session.lifecycle.summary}</span>
) : null}
</>
),
ts: formatTimeCompact(session.lastActivityAt),
});
if (session.pr) {
events.push({
tone: "good",
node: (
<>
Opened <b>PR #{session.pr.number}</b>
{session.pr.baseBranch ? ` against ${session.pr.baseBranch}` : ""}
</>
),
ts: null,
});
}
events.push({
tone: "neutral",
node: <>Created worktree &amp; branch</>,
ts: formatTimeCompact(session.createdAt),
});
return (
<div className="inspector-timeline">
{events.map((event, index) => (
<div
key={index}
className={cn(
"inspector-timeline__ev",
event.tone === "now" && "inspector-timeline__ev--now",
event.tone === "good" && "inspector-timeline__ev--good",
event.tone === "warn" && "inspector-timeline__ev--warn",
)}
>
<span className="inspector-timeline__node" aria-hidden="true" />
<div className="inspector-timeline__et">{event.node}</div>
{event.ts ? <div className="inspector-timeline__ets">{event.ts}</div> : null}
</div>
))}
</div>
);
}
function ChangesView({ session }: { session: DashboardSession }) {
const pr = session.pr;
if (!pr || (pr.additions === 0 && pr.deletions === 0)) {
return (
<div role="tabpanel">
<p className="inspector-empty">No changes pushed yet.</p>
</div>
);
}
const files = pr.changedFiles ?? 0;
return (
<div role="tabpanel">
<Section
title="Working tree"
action={
<a
href={`${pr.url}/files`}
target="_blank"
rel="noopener noreferrer"
className="inspector-section__link"
>
View diff
</a>
}
>
<div className="inspector-diff-sum">
{files > 0 ? <span>{`${files} file${files === 1 ? "" : "s"}`}</span> : null}
<span className="session-detail-diff--add">+{pr.additions}</span>
<span className="session-detail-diff--del">{pr.deletions}</span>
</div>
<p className="inspector-hint">
The full diff opens on GitHub. Inline diff rendering is a future inspector view.
</p>
</Section>
</div>
);
}
function BrowserView() {
return (
<div role="tabpanel">
<div className="inspector-empty inspector-empty--browser">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<line x1="3" y1="12" x2="21" y2="12" />
<path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" />
</svg>
<p>No live browser preview.</p>
<span>
A browser plugin (web preview / Playwright) will render what the agent is viewing here.
</span>
</div>
</div>
);
}
function Row({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
return (
<div className="inspector-kv__row">
<dt className="inspector-kv__k">{k}</dt>
<dd className={cn("inspector-kv__v", mono && "inspector-kv__v--mono")}>{v}</dd>
</div>
);
}

View File

@ -8,7 +8,7 @@ interface EmptyStateProps {
spawnDisabled?: boolean;
}
const KANBAN_GHOST_COLUMNS = ["Working", "Pending", "Review", "Respond", "Merge"] as const;
const KANBAN_GHOST_COLUMNS = ["Working", "Needs you", "In review", "Ready to merge"] as const;
export function EmptyState({
message,
@ -35,8 +35,8 @@ export function EmptyState({
cx="12"
cy="5.5"
r="2.5"
fill="rgba(249,115,22,0.18)"
stroke="#f97316"
fill="color-mix(in srgb, var(--color-accent) 18%, transparent)"
stroke="var(--color-accent)"
strokeWidth="1.5"
/>
<circle
@ -60,7 +60,7 @@ export function EmptyState({
y1="8"
x2="6.7"
y2="14.8"
stroke="rgba(249,115,22,0.22)"
stroke="color-mix(in srgb, var(--color-accent) 22%, transparent)"
strokeWidth="1"
strokeDasharray="2.5 2"
/>
@ -69,7 +69,7 @@ export function EmptyState({
y1="8"
x2="17.3"
y2="14.8"
stroke="rgba(249,115,22,0.22)"
stroke="color-mix(in srgb, var(--color-accent) 22%, transparent)"
strokeWidth="1"
strokeDasharray="2.5 2"
/>

View File

@ -0,0 +1,44 @@
"use client";
import { memo } from "react";
import { getStatusSpec, type StatusSpec } from "@/lib/status-spec";
import type { DashboardSession } from "@/lib/types";
type StatusBadgeVariant = "inline" | "pill" | "dot";
interface StatusBadgeProps {
/** Provide a session to derive the status, or pass an explicit spec. */
session?: DashboardSession;
spec?: StatusSpec;
/** inline = dot + colored label (kanban card); pill = bordered tinted pill
* (session topbar); dot = dot only (sidebar). */
variant?: StatusBadgeVariant;
className?: string;
}
/**
* The one status presenter used across the dashboard kanban card badge,
* session topbar pill, and sidebar dot all render from the same StatusSpec
* so the status system stays single-sourced (see lib/status-spec.ts).
*/
function StatusBadgeView({ session, spec, variant = "inline", className }: StatusBadgeProps) {
const resolved = spec ?? (session ? getStatusSpec(session) : null);
if (!resolved) return null;
const classes = ["status-badge", `status-badge--${variant}`, className]
.filter(Boolean)
.join(" ");
return (
<span
className={classes}
data-tone={resolved.tone}
data-breathing={resolved.breathing ? "" : undefined}
>
<span className="status-badge__dot" aria-hidden="true" />
{variant !== "dot" ? <span className="status-badge__label">{resolved.label}</span> : null}
</span>
);
}
export const StatusBadge = memo(StatusBadgeView);

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { AppMark } from "@/components/AppMark";
describe("AppMark", () => {
it("renders the blue mascot tile with the mascot image", () => {
const { container } = render(<AppMark />);
const mark = container.querySelector(".app-mark");
expect(mark).toBeInTheDocument();
const img = container.querySelector(".app-mark__img");
expect(img).toBeInTheDocument();
expect(img?.getAttribute("src")).toContain("mascot.png");
});
});

View File

@ -39,8 +39,10 @@ describe("Dashboard kanban layout", () => {
const board = document.querySelector(".kanban-board");
expect(board).toHaveAttribute("data-columns", "4");
expect(board).toHaveStyle({ "--kanban-column-count": "4" });
expect(screen.getByText("Action")).toBeInTheDocument();
expect(screen.queryByText("Respond")).not.toBeInTheDocument();
const titles = Array.from(document.querySelectorAll(".kanban-column__title")).map(
(el) => el.textContent,
);
expect(titles).toEqual(["Working", "Needs you", "In review", "Ready to merge"]);
});
it("uses five board columns in detailed attention mode", () => {
@ -63,6 +65,6 @@ describe("Dashboard kanban layout", () => {
const board = document.querySelector(".kanban-board");
expect(board).toHaveAttribute("data-columns", "5");
expect(board).toHaveStyle({ "--kanban-column-count": "5" });
expect(screen.getByText("Respond")).toBeInTheDocument();
expect(screen.getByText("Needs you")).toBeInTheDocument();
});
});

View File

@ -23,6 +23,8 @@ vi.mock("../terminal/useFullscreenResize", () => ({
}));
class MockTerminal {
static loadedAddons: unknown[] = [];
static lastOptions: Record<string, unknown> | null = null;
options: Record<string, unknown>;
parser = {
registerCsiHandler: vi.fn(),
@ -30,12 +32,16 @@ class MockTerminal {
};
cols = 80;
rows = 24;
unicode = { activeVersion: "" };
constructor(options: Record<string, unknown>) {
this.options = options;
MockTerminal.lastOptions = options;
}
loadAddon() {}
loadAddon(addon: unknown) {
MockTerminal.loadedAddons.push(addon);
}
open() {}
write() {}
refresh() {}
@ -64,6 +70,15 @@ function MockWebLinksAddon() {
return undefined;
}
class MockWebglAddon {
onContextLoss() {}
dispose() {}
}
class MockUnicode11Addon {
dispose() {}
}
class MockWebSocket {
static OPEN = 1;
static instances: MockWebSocket[] = [];
@ -95,6 +110,14 @@ vi.mock("@xterm/addon-web-links", () => ({
WebLinksAddon: MockWebLinksAddon,
}));
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: MockWebglAddon,
}));
vi.mock("@xterm/addon-unicode11", () => ({
Unicode11Addon: MockUnicode11Addon,
}));
vi.mock("@/hooks/useMux", () => ({
useMux: () => ({
subscribeTerminal: vi.fn(() => vi.fn()),
@ -111,6 +134,8 @@ vi.mock("@/hooks/useMux", () => ({
describe("DirectTerminal render", () => {
beforeEach(() => {
searchParams = new URLSearchParams();
MockTerminal.loadedAddons = [];
MockTerminal.lastOptions = null;
replaceMock.mockReset();
useFullscreenResizeMock.mockReset();
MockWebSocket.instances = [];
@ -151,9 +176,11 @@ describe("DirectTerminal render", () => {
/>,
);
await waitFor(() => expect(screen.getByText("Connected")).toBeInTheDocument());
// The mockup term-head shows no connection-status text — the mono session
// id is the chrome's identity marker now.
await waitFor(() => expect(screen.getByText("ao-orchestrator")).toBeInTheDocument());
expect(screen.getByText("ao-orchestrator")).toHaveStyle({ color: "var(--color-accent)" });
expect(screen.queryByText("Connected")).toBeNull();
expect(screen.queryByText("XDA")).toBeNull();
});
@ -206,6 +233,45 @@ describe("DirectTerminal render", () => {
expect(terminalShell).not.toHaveClass("fixed");
});
it("enforces a dark-mode contrast floor so low-contrast agent output stays legible", async () => {
render(
<DirectTerminal sessionId="ao-orchestrator" tmuxName="ao-orchestrator" variant="orchestrator" />,
);
// useTheme is mocked to "dark"; the terminal must enforce a contrast floor > 1
// so ANSI white-on-white blocks (Claude Code's expanded command) stay readable.
await waitFor(() => expect(MockTerminal.lastOptions).not.toBeNull());
expect(MockTerminal.lastOptions?.minimumContrastRatio).toBe(4.5);
// out-of-font glyphs (arrows, CJK, emoji) must be rescaled to their cell so
// a wide fallback glyph can't overlap the following character
expect(MockTerminal.lastOptions?.rescaleOverlappingGlyphs).toBe(true);
});
it("loads the Unicode 11 addon so emoji widths match modern terminals", async () => {
render(
<DirectTerminal sessionId="ao-orchestrator" tmuxName="ao-orchestrator" variant="orchestrator" />,
);
await waitFor(() =>
expect(
MockTerminal.loadedAddons.some((addon) => addon instanceof MockUnicode11Addon),
).toBe(true),
);
});
it("loads the WebGL renderer addon for crisp box-drawing", async () => {
render(
<DirectTerminal sessionId="ao-orchestrator" tmuxName="ao-orchestrator" variant="orchestrator" />,
);
// The addon is loaded rAF-deferred after open(), so wait for it to attach.
await waitFor(() =>
expect(
MockTerminal.loadedAddons.some((addon) => addon instanceof MockWebglAddon),
).toBe(true),
);
});
it("passes projectId to fullscreen resize hook for scoped mux resize", () => {
render(<DirectTerminal sessionId="app-1" projectId="project-a" tmuxName="project-a-app-1" />);

View File

@ -59,9 +59,9 @@ describe("buildTerminalThemes", () => {
}
});
it("dark theme background is #0a0a0f", () => {
it("dark theme background is the mission-control terminal surface", () => {
const { dark } = buildTerminalThemes("agent");
expect(dark.background).toBe("#0a0a0f");
expect(dark.background).toBe("#0c0d10");
});
it("orchestrator variant reuses the shared design-system accent", () => {

View File

@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionCard } from "../SessionCard";
import { makePR, makeSession } from "../../__tests__/helpers";
@ -136,14 +136,11 @@ describe("SessionCard diff coverage", () => {
);
});
it("requests a review from active worker cards", async () => {
const onReview = vi.fn(async () => {});
render(<SessionCard session={makeSession({ id: "reviewable-1" })} onReview={onReview} />);
it("shows a hover-revealed terminal link on active worker cards", () => {
render(<SessionCard session={makeSession({ id: "reviewable-1" })} />);
fireEvent.click(screen.getByRole("button", { name: "Request review" }));
await waitFor(() => {
expect(onReview).toHaveBeenCalledWith("reviewable-1");
});
expect(screen.getByText("terminal").closest("a")).toHaveClass(
"session-card__terminal-link",
);
});
});

View File

@ -123,8 +123,15 @@ describe("SessionDetail desktop layout", () => {
/>,
);
expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument();
expect(screen.getAllByText("My App").length).toBeGreaterThanOrEqual(1);
// On desktop the topbar has no sidebar toggle — the sidebar carries its own
// collapse/expand affordance, so the redundant topbar toggle is mobile-only.
expect(screen.queryByRole("button", { name: "Toggle sidebar" })).not.toBeInTheDocument();
// Worker topbar leads with the " Kanban" back link to the project board
// (the project brand now lives in the sidebar, not the topbar).
expect(within(screen.getByRole("banner")).getByRole("link", { name: "Kanban" })).toHaveAttribute(
"href",
"/projects/my-app",
);
// Scope to topbar since MobileBottomNav also has an Orchestrator link
expect(
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
@ -134,14 +141,9 @@ describe("SessionDetail desktop layout", () => {
"href",
"https://github.com/acme/app/tree/feat/desktop-detail",
);
// PR button is anchored to the PR URL (ctrl-click opens on GitHub, plain click toggles popover)
const prButton = screen.getByRole("link", { name: "PR #310" });
expect(prButton).toHaveAttribute("href", "https://github.com/acme/app/pull/100");
// PR details (blockers, file count, unresolved comments) now live inside a
// popover anchored to the PR button. Click to open it before asserting contents.
fireEvent.click(prButton);
// On desktop, PR details (blockers, file count, unresolved comments) live in
// the always-visible inspector rail Summary view — no popover to open.
expect(screen.getByText("3 files")).toBeInTheDocument();
expect(screen.getByText("Draft")).toBeInTheDocument();
expect(screen.getByText(/Changes requested/i)).toBeInTheDocument();
@ -175,9 +177,8 @@ describe("SessionDetail desktop layout", () => {
/>,
);
// Open the PR popover (button is now a link with aria-label "PR #311")
fireEvent.click(screen.getByRole("link", { name: "PR #311" }));
// On desktop the PR card (with its unresolved comments) lives in the
// always-visible inspector rail — no popover to open first.
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Ask Agent to Fix" }));
});
@ -434,7 +435,9 @@ describe("SessionDetail desktop layout", () => {
expect(banner.getByText("My App")).toBeInTheDocument();
expect(banner.getByText("Orchestrator")).toBeInTheDocument();
expect(banner.getByText("Active")).toBeInTheDocument();
// Topbar status pill now uses the shared status system; this session's PR
// (makePR defaults to approved + mergeable) resolves to "Mergeable".
expect(banner.getByText("Mergeable")).toBeInTheDocument();
expect(banner.getByText("Fleet")).toBeInTheDocument();
expect(banner.getByText("review")).toBeInTheDocument();
expect(banner.getByText("working")).toBeInTheDocument();

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { SessionDetail } from "../SessionDetail";
import { makePR, makeSession } from "../../__tests__/helpers";
@ -82,8 +82,7 @@ describe("SessionDetail merge conflict actions", () => {
/>,
);
fireEvent.click(screen.getByRole("link", { name: "PR #99" }));
// PR card (with merge-conflict actions) is in the always-visible inspector rail.
const compare = screen.getByRole("link", { name: /Compare with base branch/i });
expect(compare).toHaveAttribute(
"href",
@ -114,7 +113,6 @@ describe("SessionDetail merge conflict actions", () => {
/>,
);
fireEvent.click(screen.getByRole("link", { name: "PR #100" }));
expect(screen.queryByRole("link", { name: /Compare with base branch/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Copy head branch name/i })).not.toBeInTheDocument();
});

View File

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { SessionInspector } from "@/components/SessionInspector";
import { makePR, makeSession } from "@/__tests__/helpers";
describe("SessionInspector", () => {
it("defaults to the Summary view with PR, Activity and Overview sections", () => {
render(
<SessionInspector
session={makeSession({
id: "insp-1",
branch: "feat/x",
pr: makePR({ number: 55, title: "Add the thing" }),
})}
/>,
);
expect(screen.getByText("Pull request")).toBeInTheDocument();
expect(screen.getByText("Activity")).toBeInTheDocument();
expect(screen.getByText("Overview")).toBeInTheDocument();
expect(screen.getByText("PR #55: Add the thing")).toBeInTheDocument();
});
it("shows an empty state when there is no pull request", () => {
render(<SessionInspector session={makeSession({ id: "insp-2", pr: null })} />);
expect(screen.getByText("No pull request opened yet.")).toBeInTheDocument();
});
it("switches to the Changes view", () => {
render(
<SessionInspector
session={makeSession({
id: "insp-3",
pr: makePR({ number: 56, additions: 18, deletions: 4, changedFiles: 3 }),
})}
/>,
);
fireEvent.click(screen.getByRole("tab", { name: "Changes" }));
expect(screen.getByText("Working tree")).toBeInTheDocument();
expect(screen.getByText("3 files")).toBeInTheDocument();
});
it("switches to the Browser view placeholder", () => {
render(<SessionInspector session={makeSession({ id: "insp-4" })} />);
fireEvent.click(screen.getByRole("tab", { name: "Browser" }));
expect(screen.getByText("No live browser preview.")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { StatusBadge } from "@/components/StatusBadge";
import { makeSession } from "@/__tests__/helpers";
describe("StatusBadge", () => {
it("renders the dot + label with the derived tone for a working session", () => {
const { container } = render(
<StatusBadge session={makeSession({ id: "w", status: "working", activity: "active" })} />,
);
const badge = container.querySelector(".status-badge");
expect(badge).toHaveAttribute("data-tone", "working");
expect(badge).toHaveAttribute("data-breathing", "");
expect(screen.getByText("Working")).toBeInTheDocument();
});
it("renders a fail tone for a stuck session", () => {
const { container } = render(
<StatusBadge session={makeSession({ id: "s", status: "stuck", activity: "idle" })} />,
);
expect(container.querySelector(".status-badge")).toHaveAttribute("data-tone", "fail");
expect(screen.getByText("Stuck")).toBeInTheDocument();
});
it("omits the label in dot-only mode", () => {
const { container } = render(
<StatusBadge
session={makeSession({ id: "d", status: "working", activity: "active" })}
variant="dot"
/>,
);
expect(container.querySelector(".status-badge--dot")).toBeInTheDocument();
expect(screen.queryByText("Working")).not.toBeInTheDocument();
});
it("renders the pill variant from an explicit spec", () => {
const { container } = render(
<StatusBadge spec={{ tone: "ready", label: "Mergeable", breathing: false }} variant="pill" />,
);
const badge = container.querySelector(".status-badge--pill");
expect(badge).toHaveAttribute("data-tone", "ready");
expect(screen.getByText("Mergeable")).toBeInTheDocument();
});
it("renders nothing without a session or spec", () => {
const { container } = render(<StatusBadge />);
expect(container.querySelector(".status-badge")).not.toBeInTheDocument();
});
});

View File

@ -5,7 +5,6 @@ import { cn } from "@/lib/cn";
import { FONT_SIZE_MAX, FONT_SIZE_MIN } from "./terminal-font";
type MuxStatus = "connecting" | "connected" | "reconnecting" | "disconnected";
type DisplayStatus = MuxStatus | "error";
interface TerminalControlsProps {
sessionId: string;
@ -17,7 +16,8 @@ interface TerminalControlsProps {
fullscreen: boolean;
toggleFullscreen: () => void;
muxStatus: MuxStatus;
error: string | null;
/** Reserved for callers; the mockup header surfaces no connection state. */
error?: string | null;
}
export function TerminalControls({
@ -30,7 +30,6 @@ export function TerminalControls({
fullscreen,
toggleFullscreen,
muxStatus,
error,
}: TerminalControlsProps) {
const [reloading, setReloading] = useState(false);
const [reloadError, setReloadError] = useState<string | null>(null);
@ -74,51 +73,21 @@ export function TerminalControls({
}
}
// Local errors (e.g. xterm.js load failure) take priority over mux connection state
const displayStatus: DisplayStatus = error ? "error" : muxStatus;
const statusDotClass =
displayStatus === "connected"
? "bg-[var(--color-status-ready)]"
: displayStatus === "error" || displayStatus === "disconnected"
? "bg-[var(--color-status-error)]"
: "bg-[var(--color-status-attention)] animate-[pulse_1.5s_ease-in-out_infinite]";
const statusText =
displayStatus === "connected"
? "Connected"
: displayStatus === "error"
? (error ?? "Error")
: displayStatus === "disconnected"
? "Disconnected"
: "Connecting…";
const statusTextColor =
displayStatus === "connected"
? "text-[var(--color-status-ready)]"
: displayStatus === "error" || displayStatus === "disconnected"
? "text-[var(--color-status-error)]"
: "text-[var(--color-text-tertiary)]";
const accentColor = "var(--color-accent)";
const fontSizeControls = (
<div className="flex items-center">
<div className="terminal-chrome-zoom flex items-center">
<button
onClick={() => setFontSize((prev) => Math.max(FONT_SIZE_MIN, prev - 1))}
disabled={fontSize <= FONT_SIZE_MIN}
className="w-5 h-5 text-xs flex items-center justify-center rounded hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className="terminal-chrome-zoom__btn"
aria-label="Decrease font size"
>
</button>
<span className="w-9 text-center text-xs font-medium text-[var(--color-text-secondary)]">
{fontSize}px
</span>
<span className="terminal-chrome-zoom__value">{fontSize}px</span>
<button
onClick={() => setFontSize((prev) => Math.min(FONT_SIZE_MAX, prev + 1))}
disabled={fontSize >= FONT_SIZE_MAX}
className="w-5 h-5 text-xs flex items-center justify-center rounded hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
className="terminal-chrome-zoom__btn"
aria-label="Increase font size"
>
+
@ -169,36 +138,41 @@ export function TerminalControls({
<button
onClick={toggleFullscreen}
className={cn(
"flex items-center gap-1 px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)]",
!isOpenCodeSession && !chromeless && "ml-auto",
"terminal-chrome-fs-btn",
chromeless && "flex items-center gap-1 px-2 py-0.5 text-[11px]",
)}
aria-label={fullscreen ? "exit fullscreen" : "fullscreen"}
title={fullscreen ? "Exit fullscreen" : "Fullscreen"}
>
{fullscreen ? (
<>
<svg
className="h-3 w-3"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
>
<path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3" />
</svg>
exit fullscreen
<span className="terminal-chrome-fs-btn__label">exit fullscreen</span>
</>
) : (
<>
<svg
className="h-3 w-3"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
>
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" />
<path d="M8 3H5a2 2 0 00-2 2v3M16 3h3a2 2 0 012 2v3M8 21H5a2 2 0 01-2-2v-3M16 21h3a2 2 0 002-2v-3" />
</svg>
fullscreen
<span className="terminal-chrome-fs-btn__label">fullscreen</span>
</>
)}
</button>
@ -214,23 +188,10 @@ export function TerminalControls({
}
return (
<div className="terminal-chrome-bar flex items-center gap-2 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)] px-3 py-2">
{/* Pane label — matches the workspace pane-header style used elsewhere */}
<span className="terminal-chrome-pane-label">TERMINAL</span>
{/* Identity group: session name on top, status below on mobile */}
<div className="terminal-chrome-identity">
<span className="terminal-chrome-session-id font-[var(--font-mono)] text-[11px]" style={{ color: accentColor }}>
{sessionId}
</span>
<div className="terminal-chrome-status-row">
<div className={cn("h-2 w-2 shrink-0 rounded-full", statusDotClass)} />
<span
className={cn("text-[10px] font-medium uppercase tracking-[0.06em]", statusTextColor)}
>
{statusText}
</span>
</div>
</div>
<div className="terminal-chrome-bar flex items-center gap-2.5 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-base)] px-[15px]">
{/* Uppercase pane label + mono session id (no connection-status text) */}
<span className="terminal-chrome-pane-label">Terminal</span>
<span className="terminal-chrome-session-id">{sessionId}</span>
<div className="flex-1" />
{fontSizeControls}
{reloadButton}

View File

@ -3,38 +3,40 @@ import type { ITheme } from "@xterm/xterm";
export type TerminalVariant = "agent" | "orchestrator";
export function buildTerminalThemes(_variant: TerminalVariant): { dark: ITheme; light: ITheme } {
// Orchestrator and agent currently share the design-system accent; the
// variant parameter is preserved for API compatibility and future divergence.
// Mission-control terminal theme — the frame & xterm theme are ours; the PTY
// content is the agent's own ANSI. The 16-color palette is harmonized to the
// design tokens (xterm needs concrete hex, so these mirror globals.css). The
// cursor is orange — the "an agent is alive" signal — and selection is blue.
const accent = {
cursor: "#5b7ef8",
selDark: "rgba(91, 126, 248, 0.30)",
selLight: "rgba(91, 126, 248, 0.25)",
cursor: "#f59f4c",
selDark: "rgba(77, 141, 255, 0.30)",
selLight: "rgba(77, 141, 255, 0.25)",
};
const dark: ITheme = {
background: "#0a0a0f",
foreground: "#d4d4d8",
background: "#0c0d10", // --term
foreground: "#c5ccd3",
cursor: accent.cursor,
cursorAccent: "#0a0a0f",
cursorAccent: "#0c0d10",
selectionBackground: accent.selDark,
selectionInactiveBackground: "rgba(128, 128, 128, 0.2)",
// ANSI colors — slightly warmer than pure defaults
black: "#1a1a24",
red: "#ef4444",
green: "#22c55e",
yellow: "#f59e0b",
blue: "#5b7ef8",
magenta: "#a371f7",
cyan: "#22d3ee",
white: "#d4d4d8",
brightBlack: "#50506a",
brightRed: "#f87171",
brightGreen: "#4ade80",
brightYellow: "#fbbf24",
brightBlue: "#7b9cfb",
brightMagenta: "#c084fc",
brightCyan: "#67e8f9",
brightWhite: "#eeeef5",
// ANSI palette tied to the mission-control tokens
black: "#15171b", // --card
red: "#ef6b6b", // --red
green: "#74b98a", // --green
yellow: "#e8c14a", // --amber
blue: "#4d8dff", // --blue
magenta: "#a78bfa",
cyan: "#6fb3c9",
white: "#c5ccd3",
brightBlack: "#444951", // --t4
brightRed: "#ff8a8a",
brightGreen: "#8fd6a6",
brightYellow: "#f0d06b",
brightBlue: "#7eaaff", // --blue-soft
brightMagenta: "#c4b0fc",
brightCyan: "#8fcfe0",
brightWhite: "#f4f5f7", // --t1
};
const light: ITheme = {

View File

@ -81,9 +81,11 @@ export function useXtermTerminal(
import("@xterm/xterm").then((mod) => mod.Terminal),
import("@xterm/addon-fit").then((mod) => mod.FitAddon),
import("@xterm/addon-web-links").then((mod) => mod.WebLinksAddon),
import("@xterm/addon-webgl").then((mod) => mod.WebglAddon),
import("@xterm/addon-unicode11").then((mod) => mod.Unicode11Addon),
document.fonts.ready,
])
.then(([Terminal, FitAddon, WebLinksAddon]) => {
.then(([Terminal, FitAddon, WebLinksAddon, WebglAddon, Unicode11Addon]) => {
if (!mounted || !terminalRef.current) return;
const isDark = appearance === "dark" || resolvedTheme !== "light";
@ -101,15 +103,26 @@ export function useXtermTerminal(
// tall x-height. 1.2 restores visual breathing room between lines.
lineHeight: 1.2,
theme: activeTheme,
// Light mode needs an explicit contrast floor because agent UIs often emit
// dim/faint ANSI sequences that become unreadable on a near-white background.
minimumContrastRatio: isDark ? 1 : 7,
// Enforce a contrast floor in BOTH themes. Agent TUIs (e.g. Claude
// Code's expanded "shell command" block) paint regions on an ANSI
// white background; our theme's `white` ≈ `foreground` (#c5ccd3), so
// with no floor the text is the same colour as its background and the
// block renders as an unreadable grey blob. A floor makes xterm adjust
// only the failing foregrounds. Light mode uses a higher floor because
// dim/faint sequences wash out on its near-white base. (#grey-blob)
minimumContrastRatio: isDark ? 4.5 : 7,
// scrollback disabled — tmux provides scrollback/copy-mode, and leaving
// this > 0 makes FitAddon subtract DEFAULT_SCROLL_BAR_WIDTH (14px) from
// the available width, causing right-side clipping when the actual
// scrollbar is narrower or wider than assumed. Fixes #1677.
scrollback: 0,
allowProposedApi: true,
// JetBrains Mono is subset to Latin glyphs, so anything it lacks
// (arrows like → ←, CJK, emoji, powerline) is drawn from a fallback
// font whose advance can exceed our monospace cell — the glyph then
// bleeds into the next cell and overlaps the following text. This
// tells xterm to shrink any glyph wider than its cell back to fit.
rescaleOverlappingGlyphs: true,
fastScrollSensitivity: 3,
scrollSensitivity: 1,
});
@ -126,6 +139,41 @@ export function useXtermTerminal(
terminal.open(terminalRef.current);
terminalInstance.current = terminal;
// Match modern terminals' character-width tables (Unicode 11). xterm
// defaults to Unicode 6, where emoji such as ✅/❌ are 1 cell wide; tmux
// and agent TUIs lay tables out treating them as 2, so without this the
// grid shifts a column after every emoji (broken borders, stray text).
try {
terminal.loadAddon(new Unicode11Addon());
terminal.unicode.activeVersion = "11";
} catch {
// Addon optional — fall back to xterm's built-in width tables.
}
// WebGL renderer. xterm's default DOM renderer cannot tile box-drawing /
// block glyphs cleanly across rows (each row is a separate DOM line), so
// agent TUIs like Claude Code's bordered panels render with broken edges
// regardless of font or lineHeight. The WebGL renderer custom-draws those
// glyphs into each cell, so frames connect and shaded regions stay solid.
// Loaded rAF-deferred (post-open viewport sync) with a DOM fallback if the
// GPU context is lost or WebGL is unavailable (headless, blocklisted GPU).
let webglAddon: InstanceType<typeof WebglAddon> | null = null;
const webglRaf = requestAnimationFrame(() => {
if (!mounted || !terminalInstance.current) return;
try {
const addon = new WebglAddon();
addon.onContextLoss(() => {
addon.dispose();
webglAddon = null;
});
terminal.loadAddon(addon);
webglAddon = addon;
} catch {
// WebGL unavailable — xterm keeps using the DOM renderer.
webglAddon = null;
}
});
if (autoFocus) {
terminal.focus();
}
@ -333,6 +381,12 @@ export function useXtermTerminal(
cleanup = () => {
clearTimeout(deferredFitTimeout);
cancelAnimationFrame(webglRaf);
try {
webglAddon?.dispose();
} catch {
// addon may already be disposed via context-loss handler
}
resizeObserver?.disconnect();
dprMedia?.removeEventListener?.("change", handleDprChange);
cleanupTouchScroll();

Some files were not shown because too many files have changed in this diff Show More