diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 9cdfe5dce..5399239d9 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2ab5c11c..e7d2d2030 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 12ea5ac75..f9898a7fb 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -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 diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml index 756066eb7..777326624 100644 --- a/.github/workflows/deploy-vps.yml +++ b/.github/workflows/deploy-vps.yml @@ -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 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index cb21ad301..4c11a164d 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -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 diff --git a/.github/workflows/onboarding-test.yml b/.github/workflows/onboarding-test.yml index 704514a8b..ba6561b42 100644 --- a/.github/workflows/onboarding-test.yml +++ b/.github/workflows/onboarding-test.yml @@ -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: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb2c322cb..e318134eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 5f8cf574d..8fdc73661 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -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 diff --git a/DESIGN.md b/DESIGN.md index c1eab731f..bb7c84195 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 10–30+ 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 `` with tooltip | Minor: S/M/L diff badges were opaque. `` 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. | diff --git a/docs/design/dashboard-language.md b/docs/design/dashboard-language.md new file mode 100644 index 000000000..3700bae2f --- /dev/null +++ b/docs/design/dashboard-language.md @@ -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 ` +

“Address” button — icon candidates

+

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.

+
+ diff --git a/docs/design/mockups/concepts.html b/docs/design/mockups/concepts.html new file mode 100644 index 000000000..6cc43dddd --- /dev/null +++ b/docs/design/mockups/concepts.html @@ -0,0 +1,356 @@ + + + + + +AO — Design Direction Concepts + + + + + + + +
+
AO · Direction Previews
+ + + +
blue = orchestrator · orange = agents · same screen, three treatments
+
+ +
+
+
+
+
+ + + + diff --git a/docs/design/mockups/kanban.html b/docs/design/mockups/kanban.html new file mode 100644 index 000000000..a4b51a111 --- /dev/null +++ b/docs/design/mockups/kanban.html @@ -0,0 +1,248 @@ + + + + + +AO — Kanban + + + + + + +
+ + +
+
Agent Orchestrator
+
+
+
2 working
+ + +
+ +
+

Board

Live agent sessions flowing from work → review → merge.
+ +
+ + +
+
Working2
+
+
+
Workingao-204terminal
+
Brainstorm the design language & component library for the dashboard
+
session/ao-204
+
PR #2051CI running$2.41
+
+
+
Workingint-7terminal
+
Wire internal API behind the ALB with Tailscale-only ingress
+
feat/internal-api-alb-tailscale
+
drafting PR…$1.07
+
+
+
+ + +
+
Needs you2
+
+
+
Needs inputint-6terminal
+
Add API-key auth to the integrator service
+
feat/int-6-api-key-auth
+
PR #2044approve migration?$0.88
+
+
+
CI failedint-8terminal
+
Sanitize tool output in the Cortex security pipeline
+
security/cortex-sanitize-tool-output
+
PR #20492 checks failed$3.10
+
+
+
+ + +
+
In review2
+
+
+
Review pendingao-201terminal
+
Produce a high-quality HTML architecture design doc
+
docs/architecture-doc
+
PR #2038✓ CI passed$1.20
+
+
+
Changes req.mer-43terminal
+
Auth RCA improvements from the incident review
+
docs/auth-rca-improvements
+
PR #20313 comments$0.74
+
+
+
+ + +
+
Ready to merge1
+
+
+
Mergeableao-203terminal
+
Build an end-to-end onboarding test for the published npm package
+
test/onboarding-harness
+
PR #2042✓ approved$0.64
+
+
+
+ +
+ +
Done / Terminated· 32
+
+
+ + diff --git a/docs/design/mockups/mascot.png b/docs/design/mockups/mascot.png new file mode 100644 index 000000000..7eff2e3f5 Binary files /dev/null and b/docs/design/mockups/mascot.png differ diff --git a/docs/design/mockups/orchestrator-icons.html b/docs/design/mockups/orchestrator-icons.html new file mode 100644 index 000000000..dc4dc6619 --- /dev/null +++ b/docs/design/mockups/orchestrator-icons.html @@ -0,0 +1,126 @@ + + + + + +AO — Orchestrator button icon exploration + + + + + + +

Orchestrator button — icon candidates

+

The action spawns & conducts parallel agents. Two honest metaphor families: conducting / magic (ties to the blue-wand mascot lore) and coordinating many nodes (shows the fleet it manages). All shown in the real blue primary style.

+ +
Conducting / magic
+
+ +
Coordinating many agents
+
+ +
Other / literal
+
+ +
Treatment variants (icon-only · mascot)
+
+ + + + diff --git a/docs/design/mockups/orgchart-icons.html b/docs/design/mockups/orgchart-icons.html new file mode 100644 index 000000000..e9872e6f0 --- /dev/null +++ b/docs/design/mockups/orgchart-icons.html @@ -0,0 +1,59 @@ + + + + + +AO — Org-chart icon refinement + + + + +

Org-chart icon — refined variants

+

One orchestrator conducting many agents. Tuned geometry, symmetric spacing, clean elbow/bus connectors, rounded joins. Shown large (detail) and at button size (truth).

+
+ + + diff --git a/docs/design/mockups/refined.html b/docs/design/mockups/refined.html new file mode 100644 index 000000000..33964b5ce --- /dev/null +++ b/docs/design/mockups/refined.html @@ -0,0 +1,270 @@ + + + + + +AO — Refined + + + + + + +
+ + +
+
Fleet6 sessions · 3 projects
+
+ + + + +
+ +
+
+
+

Fleet

+
1 agent working · 1 needs your input · 1 ready to merge
+
+
+
1
Working
+
2
Need you
+
+
6
Total
+
+
+ +
Needs attention
+
+
+
+
feat/int-6-api-key-auth
int-6 · Sonnet 4.6 · 2m ago
+ Needs input +
+
Awaiting your approval to run the database migration on staging before continuing.
+
PR #20443 files$0.88
+
+
+
+
security/cortex-sanitize
int-8 · Opus 4.7 · 6m ago
+ CI failed +
+
typecheck failed — 2 errors in plugin-registry.ts. Agent is investigating the failure.
+
PR #20492 checks failed$3.10
+
+
+ +
Active
+
+
+
+
session/ao-204
ao-204 · Opus 4.7 · now
+ Working +
+
Building the refined design direction for the dashboard fleet board…
+
PR #2051CI running$2.41
+
+
+
+
docs/architecture-doc
ao-201 · Sonnet 4.6 · 4m ago
+ In review +
+
2 review comments from @prateek — addressing nits in CROSS_PLATFORM.md.
+
PR #2038✓ CI passed$1.20
+
+
+ +
Settled
+
+
+
+
test/onboarding-harness
ao-203 · Sonnet 4.6 · 11m ago
+ Mergeable +
+
All checks green, approved by 1 reviewer. Ready to merge.
+
PR #2042✓ approved$0.64
+
+
+
+
docs/architecture-doc-claude
ao-202 · Opus 4.7 · 18m ago
+ Idle +
+
No activity for 18m. Last action: pushed commit 2f9717f.
+
PR #2040✓ CI passed$1.95
+
+
+
+
+ + diff --git a/docs/design/mockups/session.html b/docs/design/mockups/session.html new file mode 100644 index 000000000..6d804c0f7 --- /dev/null +++ b/docs/design/mockups/session.html @@ -0,0 +1,428 @@ + + + + + +AO — Agent Session + + + + + + +
+ + + + + +
+ + +
+ Design the agent session detail page + session/ao-2-1 +
+ Working +
+ + + +
+ + +
+ + +
+
+
+ Terminalao-2-1 + + 13px + +
+ + +
+
✦ Claude Code v2.1.150 · Opus 4.7 · ~/…/worktrees/ao-2-1
+
 
+
Reusing the kanban token set — surfaces, status palette, mascot mark, SVG icons.
+
  Write(design-session.html) ⎿ wrote 182 lines
+
  Bash(open design-session.html)
+
     launched preview in browser
+
 
+
Wired overview, pull-request status, and an activity timeline into the rail.
+
  Color stays rationed — neutral by default. Working on the inspector…
+
 
+
───────────────────────────────────────────────────────────────
+
+
 
+
▸▸ bypass permissions on (shift+tab to cycle) · ⇥ for agents
+
+
+
+ + + +
+
+ + + + diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 1b7a3f593..72be6811d 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -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).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>; + }; + 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).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, + }); + } + }); }); diff --git a/packages/cli/__tests__/lib/update-check.test.ts b/packages/cli/__tests__/lib/update-check.test.ts index 889712372..6b8de7926 100644 --- a/packages/cli/__tests__/lib/update-check.test.ts +++ b/packages/cli/__tests__/lib/update-check.test.ts @@ -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(); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 4f5d86eb0..fb27adce8 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -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 | null; + let projects = + rawConfig && + typeof rawConfig === "object" && + rawConfig["projects"] && + typeof rawConfig["projects"] === "object" + ? (rawConfig["projects"] as Record | undefined>) + : null; + + if (!projects && !isCanonicalGlobalConfigPath(mutationConfigPath)) { + const globalPath = getGlobalConfigPath(); + if (existsSync(globalPath)) { + mutationConfigPath = globalPath; + rawYaml = readFileSync(mutationConfigPath, "utf-8"); + rawConfig = yamlParse(rawYaml) as Record | null; + projects = + rawConfig && + typeof rawConfig === "object" && + rawConfig["projects"] && + typeof rawConfig["projects"] === "object" + ? (rawConfig["projects"] as Record< + string, + Record | 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>) + Object.values(projects) + .filter((p): p is Record => 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); - 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)); + const rawConfig = yamlParse(rawYaml) as Record | null; + const projects = + rawConfig && + typeof rawConfig === "object" && + rawConfig["projects"] && + typeof rawConfig["projects"] === "object" + ? (rawConfig["projects"] as Record | 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 | undefined) ?? {}), + agent: orchestratorAgent, + }; + proj.worker = { + ...((proj.worker as Record | undefined) ?? {}), + agent: workerAgent, + }; + writeFileSync( + config.configPath, + configToYaml(rawConfig as Record), + ); + } 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); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index df1804213..bfd697607 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -719,6 +719,7 @@ async function showFallbackStatus(): Promise { branch: null, issueId: null, pr: null, + prs: [], workspacePath: null, runtimeHandle: lifecycle.runtime.handle, agentInfo: null, diff --git a/packages/cli/src/lib/notify-test.ts b/packages/cli/src/lib/notify-test.ts index 47b50f05a..1541c195d 100644 --- a/packages/cli/src/lib/notify-test.ts +++ b/packages/cli/src/lib/notify-test.ts @@ -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", diff --git a/packages/cli/src/lib/update-check.ts b/packages/cli/src/lib/update-check.ts index 7dc72019f..fa0bd2df4 100644 --- a/packages/cli/src/lib/update-check.ts +++ b/packages/cli/src/lib/update-check.ts @@ -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); } diff --git a/packages/core/src/__tests__/agent-workspace-hooks.test.ts b/packages/core/src/__tests__/agent-workspace-hooks.test.ts index 5da08c7f5..0c69f9bf4 100644 --- a/packages/core/src/__tests__/agent-workspace-hooks.test.ts +++ b/packages/core/src/__tests__/agent-workspace-hooks.test.ts @@ -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", () => { diff --git a/packages/core/src/__tests__/code-review-manager.test.ts b/packages/core/src/__tests__/code-review-manager.test.ts index 8c9e4802a..e30dfc049 100644 --- a/packages/core/src/__tests__/code-review-manager.test.ts +++ b/packages/core/src/__tests__/code-review-manager.test.ts @@ -78,6 +78,18 @@ function makeSession(overrides: Partial & { 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, diff --git a/packages/core/src/__tests__/dashboard-notifications.test.ts b/packages/core/src/__tests__/dashboard-notifications.test.ts index bea947638..422f2d39e 100644 --- a/packages/core/src/__tests__/dashboard-notifications.test.ts +++ b/packages/core/src/__tests__/dashboard-notifications.test.ts @@ -42,6 +42,18 @@ function makeEvent(overrides: Partial = {}): 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", diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 68edb12fe..94edbecd5 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -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(); + } + }); +}); diff --git a/packages/core/src/__tests__/notification-data.test.ts b/packages/core/src/__tests__/notification-data.test.ts index 1358d9d19..080051ed9 100644 --- a/packages/core/src/__tests__/notification-data.test.ts +++ b/packages/core/src/__tests__/notification-data.test.ts @@ -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", diff --git a/packages/core/src/__tests__/session-from-metadata.test.ts b/packages/core/src/__tests__/session-from-metadata.test.ts new file mode 100644 index 000000000..2bae23c19 --- /dev/null +++ b/packages/core/src/__tests__/session-from-metadata.test.ts @@ -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"); + }); +}); diff --git a/packages/core/src/__tests__/session-manager/claim-pr.test.ts b/packages/core/src/__tests__/session-manager/claim-pr.test.ts index 3625a0c1e..81db7b0c3 100644 --- a/packages/core/src/__tests__/session-manager/claim-pr.test.ts +++ b/packages/core/src/__tests__/session-manager/claim-pr.test.ts @@ -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); + }); }); diff --git a/packages/core/src/__tests__/test-utils.ts b/packages/core/src/__tests__/test-utils.ts index b20f05ffa..24f209f0f 100644 --- a/packages/core/src/__tests__/test-utils.ts +++ b/packages/core/src/__tests__/test-utils.ts @@ -105,6 +105,7 @@ export function makeSession(overrides: Partial = {}): 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 { return { ...base, ...overrides, + prs: overrides.prs ?? (overrides.pr ? [overrides.pr] : []), lifecycle: overrides.lifecycle ?? lifecycle, }; } diff --git a/packages/core/src/agent-workspace-hooks.ts b/packages/core/src/agent-workspace-hooks.ts index f3caaa381..1b0b25d0b 100644 --- a/packages/core/src/agent-workspace-hooks.ts +++ b/packages/core/src/agent-workspace-hooks.ts @@ -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"); diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 3cae2f77e..3055be895 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -320,26 +320,27 @@ function buildEventContext( session: Session | ReactionSessionContext, prEnrichmentCache: Map, ): 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); diff --git a/packages/core/src/notification-data.ts b/packages/core/src/notification-data.ts index 59e8d84c2..637692834 100644 --- a/packages/core/src/notification-data.ts +++ b/packages/core/src/notification-data.ts @@ -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; diff --git a/packages/core/src/recovery/validator.ts b/packages/core/src/recovery/validator.ts index 2f9a472ef..07b511274 100644 --- a/packages/core/src/recovery/validator.ts +++ b/packages/core/src/recovery/validator.ts @@ -122,6 +122,7 @@ export async function validateSession( branch: normalizedMetadata["branch"] ?? null, issueId: normalizedMetadata["issue"] ?? null, pr: null, + prs: [], workspacePath, runtimeHandle, agentInfo: null, diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 86807c47b..72d946585 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -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( + [ + 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 = { + 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" } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 87cbd6b0c..86850228e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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; diff --git a/packages/core/src/update-cache.ts b/packages/core/src/update-cache.ts index 591b7e301..0c3dbc7c6 100644 --- a/packages/core/src/update-cache.ts +++ b/packages/core/src/update-cache.ts @@ -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 } diff --git a/packages/core/src/utils/session-from-metadata.ts b/packages/core/src/utils/session-from-metadata.ts index 9230ff18b..b8c31a7f2 100644 --- a/packages/core/src/utils/session-from-metadata.ts +++ b/packages/core/src/utils/session-from-metadata.ts @@ -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, diff --git a/packages/integration-tests/src/helpers/event-factory.ts b/packages/integration-tests/src/helpers/event-factory.ts index 0a07a9d1d..66b152748 100644 --- a/packages/integration-tests/src/helpers/event-factory.ts +++ b/packages/integration-tests/src/helpers/event-factory.ts @@ -52,6 +52,7 @@ export function makeSession(overrides: Partial = {}): Session { branch: "feat/test", issueId: null, pr: null, + prs: [], workspacePath: "/tmp/workspace", runtimeHandle: null, agentInfo: null, diff --git a/packages/integration-tests/src/helpers/session-factory.ts b/packages/integration-tests/src/helpers/session-factory.ts index b5e63b8ab..db089f82d 100644 --- a/packages/integration-tests/src/helpers/session-factory.ts +++ b/packages/integration-tests/src/helpers/session-factory.ts @@ -40,6 +40,7 @@ export function makeSession( branch: null, issueId: null, pr: null, + prs: [], workspacePath, runtimeHandle: handle, agentInfo: null, diff --git a/packages/plugins/agent-aider/src/index.test.ts b/packages/plugins/agent-aider/src/index.test.ts index 95fb70093..f6a712baf 100644 --- a/packages/plugins/agent-aider/src/index.test.ts +++ b/packages/plugins/agent-aider/src/index.test.ts @@ -79,6 +79,7 @@ function makeSession(overrides: Partial = {}): Session { branch: "feat/test", issueId: null, pr: null, + prs: [], workspacePath: "/workspace/test", runtimeHandle: null, agentInfo: null, diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index a93f7d74d..06581ae35 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -47,6 +47,7 @@ function makeSession(overrides: Partial = {}): Session { branch: "main", issueId: null, pr: null, + prs: [], workspacePath, runtimeHandle: handle, agentInfo: null, diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index 8b65084b3..eb9fa2812 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -99,6 +99,7 @@ function makeSession(overrides: Partial = {}): Session { branch: "feat/test", issueId: null, pr: null, + prs: [], workspacePath: "/workspace/test-project", runtimeHandle: null, agentInfo: null, diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 934fe3be2..cbbf92ed1 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -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); diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 5499873a1..ed7abb274 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -121,6 +121,7 @@ function makeSession(overrides: Partial = {}): Session { branch: "feat/test", issueId: null, pr: null, + prs: [], workspacePath: "/workspace/test", runtimeHandle: null, agentInfo: null, diff --git a/packages/plugins/agent-cursor/src/index.test.ts b/packages/plugins/agent-cursor/src/index.test.ts index 6ddde8b84..dfd7b42eb 100644 --- a/packages/plugins/agent-cursor/src/index.test.ts +++ b/packages/plugins/agent-cursor/src/index.test.ts @@ -81,6 +81,7 @@ function makeSession(overrides: Partial = {}): Session { branch: "feat/test", issueId: null, pr: null, + prs: [], workspacePath: "/workspace/test", runtimeHandle: null, agentInfo: null, diff --git a/packages/plugins/agent-kimicode/src/index.test.ts b/packages/plugins/agent-kimicode/src/index.test.ts index 9cbbb3350..f3430e381 100644 --- a/packages/plugins/agent-kimicode/src/index.test.ts +++ b/packages/plugins/agent-kimicode/src/index.test.ts @@ -146,6 +146,7 @@ function makeSession(overrides: Partial = {}): Session { branch: "feat/test", issueId: null, pr: null, + prs: [], workspacePath: "/workspace/test", runtimeHandle: null, agentInfo: null, diff --git a/packages/plugins/agent-opencode/src/index.test.ts b/packages/plugins/agent-opencode/src/index.test.ts index 75ea49bca..5d4e0f1bf 100644 --- a/packages/plugins/agent-opencode/src/index.test.ts +++ b/packages/plugins/agent-opencode/src/index.test.ts @@ -75,6 +75,7 @@ function makeSession(overrides: Partial = {}): Session { branch: "feat/test", issueId: null, pr: null, + prs: [], workspacePath: "/workspace/test", runtimeHandle: null, agentInfo: null, diff --git a/packages/plugins/notifier-dashboard/src/index.test.ts b/packages/plugins/notifier-dashboard/src/index.test.ts index 862f95118..a5de2ba6f 100644 --- a/packages/plugins/notifier-dashboard/src/index.test.ts +++ b/packages/plugins/notifier-dashboard/src/index.test.ts @@ -41,6 +41,18 @@ function makeEvent(overrides: Partial = {}): 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", diff --git a/packages/plugins/notifier-openclaw/src/index.test.ts b/packages/plugins/notifier-openclaw/src/index.test.ts index 6cfc03597..b1fccfedf 100644 --- a/packages/plugins/notifier-openclaw/src/index.test.ts +++ b/packages/plugins/notifier-openclaw/src/index.test.ts @@ -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", diff --git a/packages/plugins/scm-github/test/index.test.ts b/packages/plugins/scm-github/test/index.test.ts index 7fee69578..0812cf01f 100644 --- a/packages/plugins/scm-github/test/index.test.ts +++ b/packages/plugins/scm-github/test/index.test.ts @@ -73,6 +73,7 @@ function makeSession(overrides: Partial = {}): Session { branch: "feat/my-feature", issueId: null, pr: null, + prs: [], workspacePath: "/tmp/repo", runtimeHandle: null, agentInfo: null, diff --git a/packages/plugins/scm-gitlab/test/index.test.ts b/packages/plugins/scm-gitlab/test/index.test.ts index c72d93ee0..4c5f87dfb 100644 --- a/packages/plugins/scm-gitlab/test/index.test.ts +++ b/packages/plugins/scm-gitlab/test/index.test.ts @@ -69,6 +69,7 @@ function makeSession(overrides: Partial = {}): Session { branch: "feat/my-feature", issueId: null, pr: null, + prs: [], workspacePath: "/tmp/repo", runtimeHandle: null, agentInfo: null, diff --git a/packages/plugins/terminal-iterm2/src/index.test.ts b/packages/plugins/terminal-iterm2/src/index.test.ts index 9508639c0..24df4b489 100644 --- a/packages/plugins/terminal-iterm2/src/index.test.ts +++ b/packages/plugins/terminal-iterm2/src/index.test.ts @@ -37,6 +37,7 @@ function makeSession(overrides: Partial = {}): Session { branch: "feat/test", issueId: null, pr: null, + prs: [], workspacePath: "/tmp/workspace", runtimeHandle: null, agentInfo: null, diff --git a/packages/plugins/terminal-web/src/index.test.ts b/packages/plugins/terminal-web/src/index.test.ts index ca377fca2..c6d8f5a83 100644 --- a/packages/plugins/terminal-web/src/index.test.ts +++ b/packages/plugins/terminal-web/src/index.test.ts @@ -23,6 +23,7 @@ function makeSession(overrides: Partial = {}): Session { branch: "feat/test", issueId: null, pr: null, + prs: [], workspacePath: "/tmp/workspace", runtimeHandle: null, agentInfo: null, diff --git a/packages/web/package.json b/packages/web/package.json index f4b5ec668..095901ab3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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", diff --git a/packages/web/public/mascot.png b/packages/web/public/mascot.png new file mode 100644 index 000000000..7eff2e3f5 Binary files /dev/null and b/packages/web/public/mascot.png differ diff --git a/packages/web/src/__tests__/activity-events-routes.test.ts b/packages/web/src/__tests__/activity-events-routes.test.ts index fbd2b474c..277f1f175 100644 --- a/packages/web/src/__tests__/activity-events-routes.test.ts +++ b/packages/web/src/__tests__/activity-events-routes.test.ts @@ -58,6 +58,7 @@ function makeSession(overrides: Partial & { id: string }): Session { lastActivityAt: new Date(), metadata: {}, ...overrides, + prs: overrides.prs ?? (overrides.pr ? [overrides.pr] : []), }; } diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 3bb2eb496..157576fb4 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -45,6 +45,7 @@ function makeSession(overrides: Partial & { id: string }): Session { lastActivityAt: new Date(), metadata: {}, ...overrides, + prs: overrides.prs ?? (overrides.pr ? [overrides.pr] : []), }; } diff --git a/packages/web/src/__tests__/components.test.tsx b/packages/web/src/__tests__/components.test.tsx index 6fe8c1df6..02603e540 100644 --- a/packages/web/src/__tests__/components.test.tsx +++ b/packages/web/src/__tests__/components.test.tsx @@ -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(); - 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(); 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(); - 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( -
- -
, - ); - 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(); - 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(); - 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(); + 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(); - 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(); - 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(); - 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(); - expect(screen.getByRole("button", { name: "Ask to fix" })).toBeInTheDocument(); + const { container } = render(); + 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(); - 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(); - 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((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(); - - 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(); 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(); + expect(screen.getByText("terminal")).toBeInTheDocument(); + + rerender( + , ); - 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((resolve) => { - resolveSend = resolve; - }), - ); - const session = makeSession({ - id: "respond-2", - status: "needs_input", - activity: "waiting_input", - summary: "Need approval to proceed", - }); - - render(); - - 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(); - - 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(); - - 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(); // 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(); - 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(); - // 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(); - 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(); - 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", () => { diff --git a/packages/web/src/__tests__/helpers.ts b/packages/web/src/__tests__/helpers.ts index 38d9544eb..545a883d6 100644 --- a/packages/web/src/__tests__/helpers.ts +++ b/packages/web/src/__tests__/helpers.ts @@ -219,6 +219,7 @@ export function makeSession(overrides: Partial = {}): Dashboar ...baseSession, ...overrides, issueLabel: overrides.issueLabel ?? overrides.issueId ?? baseSession.issueLabel, + prs: overrides.prs ?? (overrides.pr ? [overrides.pr] : []), } satisfies DashboardSession; return { diff --git a/packages/web/src/__tests__/next-font-mock.ts b/packages/web/src/__tests__/next-font-mock.ts new file mode 100644 index 000000000..1cd428618 --- /dev/null +++ b/packages/web/src/__tests__/next-font-mock.ts @@ -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" }, + }; +} diff --git a/packages/web/src/__tests__/prompt-spawn.test.ts b/packages/web/src/__tests__/prompt-spawn.test.ts index f3f111f4b..f1b585714 100644 --- a/packages/web/src/__tests__/prompt-spawn.test.ts +++ b/packages/web/src/__tests__/prompt-spawn.test.ts @@ -54,6 +54,7 @@ function makeSession(overrides: Partial & { id: string }): Session { lastActivityAt: new Date("2025-01-01T00:00:00Z"), metadata: {}, ...overrides, + prs: overrides?.prs ?? (overrides?.pr ? [overrides.pr] : []), }; } diff --git a/packages/web/src/app/api/prs/[id]/merge/route.ts b/packages/web/src/app/api/prs/[id]/merge/route.ts index c996a60f9..d4ea77d07 100644 --- a/packages/web/src/app/api/prs/[id]/merge/route.ts +++ b/packages/web/src/app/api/prs/[id]/merge/route.ts @@ -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", diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index c9645bafd..b0cd6b151 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -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]); } } diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 0980506d7..c18559850 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -26,9 +26,10 @@ /* ── Light mode (default) ─────────────────────────────────────────── */ :root { - --font-geist-sans: - "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; - --font-jetbrains-mono: "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace; + /* --font-schibsted-grotesk (UI / product voice) and --font-jetbrains-mono + (machine voice) are injected on by next/font/local (src/fonts/fonts.ts). + Do NOT redeclare them here — a :root value would shadow next/font's. The + system fallbacks live in the --font-sans / --font-mono chains in @theme. */ /* Base surfaces — design-system light palette */ --color-bg-base: #f5f3f0; @@ -181,150 +182,179 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - /* Base surfaces — warm charcoal (brown-tinted, not blue/neutral) */ - --color-bg-base: #121110; - --color-bg-surface: #1a1918; - --color-bg-elevated: #222120; - --color-bg-elevated-hover: #2a2928; - --color-bg-subtle: rgba(255, 240, 220, 0.05); - --color-bg-hover: rgba(255, 240, 220, 0.08); - --color-bg-inset: #161514; - --color-bg-sidebar: #0e0d0c; - --color-bg-card: #1c1b19; + /* ════════════════════════════════════════════════════════════════════ + MISSION-CONTROL PALETTE (supersedes "Warm Terminal" — see DESIGN.md) + Cool, restrained control-room dark. Color = meaning; most states get + none. Blue = orchestrator/you; orange = a working agent; amber = + needs-input; red = failing/stuck; green = mergeable; else grayscale. + These literal tokens are the source; the --color-* semantic tokens + below alias them so the rest of the stylesheet keeps working. */ + --bg: #0a0b0d; /* app base */ + --bg-side: #08090b; /* sidebar */ + --bg-top: #0a0b0d; /* topbar */ + --col: #0e0f12; /* kanban column trough */ + --card: #15171b; /* the only bordered surface */ + --card-hover: #191b20; + --term: #0c0d10; /* terminal / xterm background */ - /* Borders — warm-tinted transparency */ - --color-border-subtle: rgba(255, 240, 220, 0.07); - --color-border-default: rgba(255, 240, 220, 0.13); - --color-border-strong: rgba(255, 240, 220, 0.24); + --t1: #f4f5f7; /* text: primary */ + --t2: #9ba1aa; /* text: secondary */ + --t3: #646a73; /* text: tertiary */ + --t4: #444951; /* text: faint */ - /* Text — cream, not blue-white */ - --color-text-primary: #f0ece8; - --color-text-secondary: #a8a29e; - --color-text-tertiary: #78716c; - --color-text-muted: #57534e; - --color-text-inverse: #121110; + --line: rgba(255, 255, 255, 0.06); + --line-2: rgba(255, 255, 255, 0.1); + --hover: rgba(255, 255, 255, 0.04); + --hover-2: rgba(255, 255, 255, 0.07); - /* Interactive accent — warm periwinkle */ - --color-accent: #8b9cf7; - --color-accent-hover: #a3b1fa; - --color-accent-subtle: rgba(139, 156, 247, 0.12); + --blue: #4d8dff; /* orchestrator / you */ + --blue-soft: #7eaaff; + --orange: #f59f4c; /* a working agent */ + --amber: #e8c14a; /* needs-your-input / attention */ + --red: #ef6b6b; /* failing / stuck */ + --green: #74b98a; /* mergeable / passed / resolved */ - /* Amber accent — orchestrator / CTA */ - --color-accent-amber: #f97316; - --color-accent-amber-dim: rgba(249, 115, 22, 0.12); - --color-accent-amber-border: rgba(249, 115, 22, 0.35); + /* Base surfaces */ + --color-bg-base: var(--bg); + --color-bg-surface: var(--card); + --color-bg-elevated: var(--card-hover); + --color-bg-elevated-hover: var(--card-hover); + --color-bg-subtle: var(--hover); + --color-bg-hover: var(--hover-2); + --color-bg-inset: var(--col); + --color-bg-sidebar: var(--bg-side); + --color-bg-card: var(--card); - /* Status */ - --color-status-working: #22c55e; - --color-status-ready: #8b9cf7; - --color-status-respond: #ea580c; - --color-status-review: #06b6d4; - --color-status-pending: #eab308; - --color-status-attention: #e2a336; - --color-status-idle: #44403c; - --color-status-done: #44403c; - --color-status-error: #ef4444; - --color-status-merge: #22c55e; - --color-ci-pass: #22c55e; - --color-ci-fail: #ef4444; + /* Borders / hairlines */ + --color-border-subtle: var(--line); + --color-border-default: var(--line); + --color-border-strong: var(--line-2); + + /* Text ramp — primary → faint */ + --color-text-primary: var(--t1); + --color-text-secondary: var(--t2); + --color-text-tertiary: var(--t3); + --color-text-muted: var(--t4); + --color-text-inverse: #ffffff; + + /* Interactive accent — blue = orchestrator / you (links, focus, selection) */ + --color-accent: var(--blue); + --color-accent-hover: #5b97ff; + --color-accent-subtle: rgba(77, 141, 255, 0.12); + + /* Amber accent — needs-input / attention (NOT the orchestrator CTA anymore) */ + --color-accent-amber: var(--amber); + --color-accent-amber-dim: rgba(232, 193, 74, 0.12); + --color-accent-amber-border: rgba(232, 193, 74, 0.32); + + /* Status — one semantic spectrum mapped to the canonical lifecycle */ + --color-status-working: var(--orange); /* agent alive right now (breathing) */ + --color-status-ready: var(--green); /* ready to merge */ + --color-status-respond: var(--amber); /* needs your input */ + --color-status-review: var(--t2); /* in review = neutral */ + --color-status-pending: var(--t3); /* waiting on external = neutral */ + --color-status-attention: var(--amber); + --color-status-idle: var(--t4); + --color-status-done: var(--t4); + --color-status-error: var(--red); + --color-status-merge: var(--green); + --color-ci-pass: var(--green); + --color-ci-fail: var(--red); /* Semantic aliases */ - --color-accent-blue: #60a5fa; - --color-accent-green: #22c55e; - --color-accent-yellow: #eab308; - --color-accent-orange: #ff9d57; - --color-accent-red: #ef4444; - --color-accent-violet: #a78bfa; - --color-accent-purple: #a78bfa; + --color-accent-blue: var(--blue); + --color-accent-green: var(--green); + --color-accent-yellow: var(--amber); + --color-accent-orange: var(--orange); + --color-accent-red: var(--red); + --color-accent-violet: var(--blue-soft); + --color-accent-purple: var(--blue-soft); - /* Theme-adaptive tints */ - --color-tint-blue: rgba(96, 165, 250, 0.12); - --color-tint-green: rgba(34, 197, 94, 0.12); - --color-tint-yellow: rgba(226, 163, 54, 0.12); - --color-tint-red: rgba(239, 68, 68, 0.12); - --color-tint-violet: rgba(139, 156, 247, 0.12); - --color-tint-orange: rgba(255, 157, 87, 0.12); + /* Theme-adaptive tints (bg behind status pills/badges) */ + --color-tint-blue: rgba(77, 141, 255, 0.12); + --color-tint-green: rgba(116, 185, 138, 0.13); + --color-tint-yellow: rgba(232, 193, 74, 0.13); + --color-tint-red: rgba(239, 107, 107, 0.12); + --color-tint-violet: rgba(77, 141, 255, 0.1); + --color-tint-orange: rgba(245, 159, 76, 0.13); --color-tint-neutral: rgba(255, 255, 255, 0.05); /* Chip/badge background */ - --color-chip-bg: rgba(255, 240, 220, 0.06); + --color-chip-bg: var(--hover-2); /* Hover overlay */ - --color-hover-overlay: rgba(255, 240, 220, 0.05); + --color-hover-overlay: var(--hover); /* Scrollbar */ --color-scrollbar: rgba(255, 255, 255, 0.08); --color-scrollbar-hover: rgba(255, 255, 255, 0.15); --color-scrollbar-active: rgba(255, 255, 255, 0.25); - /* Body — warm ambient glow, not blue radials */ - --color-body-gradient-blue: rgba(139, 156, 247, 0.08); - --color-body-gradient-violet: rgba(139, 120, 100, 0.06); + /* Body — flat base, no ambient glow (the calm control room) */ + --color-body-gradient-blue: transparent; + --color-body-gradient-violet: transparent; /* Nav glass */ - --color-nav-bg: rgba(18, 17, 16, 0.82); + --color-nav-bg: rgba(10, 11, 13, 0.82); - /* Card surfaces — match cards.html dark reference */ - --card-bg: var(--color-bg-card); - --card-merge-bg: rgba(20, 22, 18, 0.98); - --card-expanded-bg: linear-gradient( - 180deg, - rgba(34, 33, 32, 0.98) 0%, - rgba(26, 25, 24, 0.98) 100% - ); - --card-shadow: 0 18px 36px rgba(0, 0, 0, 0.24); - --card-shadow-hover: 0 24px 54px rgba(0, 0, 0, 0.34); - --card-inset: inset 0 1px 0 rgba(255, 255, 255, 0.03); - --card-border: var(--color-border-subtle); + /* Card surfaces — the card is the only bordered box; flat, hairline ring */ + --card-bg: var(--card); + --card-merge-bg: var(--card); + --card-expanded-bg: var(--card-hover); + --card-shadow: none; + --card-shadow-hover: none; + --card-inset: none; + --card-border: var(--line); /* Done card surfaces */ - --card-done-bg: linear-gradient(180deg, rgba(30, 29, 28, 0.8) 0%, rgba(24, 23, 22, 0.8) 100%); - --card-done-border: rgba(255, 240, 220, 0.12); + --card-done-bg: var(--card); + --card-done-border: var(--line); - --done-pill-exited-bg: rgba(255, 240, 220, 0.06); - --done-pill-exited-color: #a8a29e; - --done-pill-merged-bg: rgba(95, 211, 154, 0.14); - --done-pill-merged-color: #5fd39a; - --done-pill-killed-bg: rgba(255, 123, 114, 0.12); - --done-pill-killed-color: #ff7b72; - --done-meta-chip-bg: rgba(255, 240, 220, 0.04); - --done-meta-chip-border: rgba(255, 240, 220, 0.08); - --done-section-border: rgba(255, 240, 220, 0.06); - --done-title-color: #a8a29e; - --done-restore-bg: rgba(139, 156, 247, 0.1); - --done-restore-border: rgba(139, 156, 247, 0.24); - --done-restore-hover-bg: rgba(139, 156, 247, 0.18); + --done-pill-exited-bg: var(--hover-2); + --done-pill-exited-color: var(--t3); + --done-pill-merged-bg: rgba(116, 185, 138, 0.14); + --done-pill-merged-color: var(--green); + --done-pill-killed-bg: rgba(239, 107, 107, 0.12); + --done-pill-killed-color: var(--red); + --done-meta-chip-bg: var(--hover); + --done-meta-chip-border: var(--line); + --done-section-border: var(--line); + --done-title-color: var(--t2); + --done-restore-bg: rgba(77, 141, 255, 0.1); + --done-restore-border: rgba(77, 141, 255, 0.24); + --done-restore-hover-bg: rgba(77, 141, 255, 0.18); /* Detail card */ - --detail-card-bg: linear-gradient(180deg, rgba(26, 25, 24, 0.98) 0%, rgba(18, 17, 16, 0.98) 100%); - --detail-card-shadow: 0 18px 42px rgba(0, 0, 0, 0.28); + --detail-card-bg: var(--card); + --detail-card-shadow: none; - /* Orchestrator button */ - --btn-shadow: 0 10px 24px rgba(0, 0, 0, 0.16); - --btn-shadow-hover: 0 16px 34px rgba(0, 0, 0, 0.22); - --btn-inset: inset 0 1px 0 rgba(255, 255, 255, 0.06); + /* Buttons — flat in the control room */ + --btn-shadow: none; + --btn-shadow-hover: none; + --btn-inset: none; - /* Kanban column */ - --color-column-bg: var(--color-bg-subtle); + /* Kanban column trough */ + --color-column-bg: var(--col); --color-column-header: transparent; - /* Alert colors — warm-tinted */ - --color-alert-ci: #ef4444; - --color-alert-ci-bg: rgba(239, 68, 68, 0.08); - --color-alert-ci-unknown: #e2a336; - --color-alert-review: #60a5fa; - --color-alert-review-bg: rgba(96, 165, 250, 0.08); - --color-alert-changes: #f97316; - --color-alert-changes-bg: rgba(249, 115, 22, 0.08); - --color-alert-conflict: #eab308; - --color-alert-conflict-bg: rgba(234, 179, 8, 0.08); - --color-alert-comment: #a78bfa; - --color-alert-comment-bg: rgba(167, 139, 250, 0.08); + /* Alert colors */ + --color-alert-ci: var(--red); + --color-alert-ci-bg: rgba(239, 107, 107, 0.08); + --color-alert-ci-unknown: var(--amber); + --color-alert-review: var(--blue); + --color-alert-review-bg: rgba(77, 141, 255, 0.08); + --color-alert-changes: var(--amber); + --color-alert-changes-bg: rgba(232, 193, 74, 0.08); + --color-alert-conflict: var(--red); + --color-alert-conflict-bg: rgba(239, 107, 107, 0.08); + --color-alert-comment: var(--amber); + --color-alert-comment-bg: rgba(232, 193, 74, 0.08); } @theme { - /* ── Typography ──────────────────────────────────────────────────── */ - --font-sans: var(--font-geist-sans), "SF Pro Text", -apple-system, system-ui, sans-serif; + /* ── Typography — mission-control voices ─────────────────────────── */ + /* UI = Schibsted Grotesk (product voice); machine/data = JetBrains Mono. */ + --font-sans: var(--font-schibsted-grotesk), "SF Pro Text", -apple-system, system-ui, sans-serif; --font-mono: var(--font-jetbrains-mono), "SF Mono", "Menlo", "Consolas", monospace; /* ── Border radius ───────────────────────────────────────────────── */ @@ -381,36 +411,17 @@ 0 4px 8px rgba(0, 0, 0, 0.85), 0 14px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.1); - /* Gradient surfaces — warm tints */ - --gradient-session-card: linear-gradient( - 175deg, - rgba(30, 29, 28, 1) 0%, - rgba(23, 22, 21, 1) 100% - ); - --gradient-session-card-expanded: linear-gradient( - 175deg, - rgba(38, 37, 36, 1) 0%, - rgba(30, 29, 28, 1) 100% - ); - --gradient-session-card-merge: linear-gradient( - 175deg, - rgba(23, 25, 22, 1) 0%, - rgba(17, 18, 16, 1) 100% - ); - --gradient-detail-card: linear-gradient(175deg, rgba(30, 29, 28, 1) 0%, rgba(23, 22, 21, 1) 100%); - --gradient-orchestrator-btn: linear-gradient( - 175deg, - rgba(139, 156, 247, 0.12) 0%, - rgba(139, 156, 247, 0.06) 100% - ); - --gradient-orchestrator-btn-hover: linear-gradient( - 175deg, - rgba(139, 156, 247, 0.18) 0%, - rgba(139, 156, 247, 0.1) 100% - ); + /* Gradient surfaces — flat cool fills in the mission-control language. + (Resolve to the card/accent tokens so they stay theme-correct.) */ + --gradient-session-card: var(--color-bg-card); + --gradient-session-card-expanded: var(--color-bg-elevated-hover); + --gradient-session-card-merge: var(--color-bg-card); + --gradient-detail-card: var(--color-bg-card); + --gradient-orchestrator-btn: var(--color-accent); + --gradient-orchestrator-btn-hover: var(--color-accent-hover); --gradient-status-strip: linear-gradient( to bottom, - rgba(139, 156, 247, 0.04) 0%, + var(--color-accent-subtle) 0%, transparent 100% ); } @@ -1085,31 +1096,20 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { /* ── Orchestrator button ──────────────────────────────────────────────── */ +/* The Orchestrator / primary CTA — the only solid-fill blue button. */ .orchestrator-btn { - border-radius: 0; - color: var(--color-accent); - background: linear-gradient( - 175deg, - color-mix(in srgb, var(--color-accent) 12%, transparent) 0%, - color-mix(in srgb, var(--color-accent) 6%, transparent) 100% - ); - border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent); + border-radius: 7px; + color: var(--color-text-inverse); + background: var(--color-accent); + border: 1px solid transparent; box-shadow: var(--btn-shadow); transition: transform 0.16s ease-out, - box-shadow 0.12s ease, - background 0.12s ease, - border-color 0.12s ease; + background 0.12s ease; } .orchestrator-btn:hover { - transform: translateY(-1px); - background: linear-gradient( - 175deg, - color-mix(in srgb, var(--color-accent) 18%, transparent) 0%, - color-mix(in srgb, var(--color-accent) 10%, transparent) 100% - ); - border-color: color-mix(in srgb, var(--color-accent) 45%, transparent); + background: var(--color-accent-hover); box-shadow: var(--btn-shadow-hover); } @@ -1308,15 +1308,28 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { color: var(--color-accent-amber); } +/* Secondary nav button (e.g. Open Kanban) — calm blue-tinted, not amber. */ .dashboard-app-btn--amber { - border-color: var(--color-accent-amber-border); - background: var(--color-accent-amber-dim); - color: var(--color-accent-amber); + border-color: color-mix(in srgb, var(--color-accent) 28%, transparent); + background: var(--color-accent-subtle); + color: var(--color-accent); } .dashboard-app-btn--amber:hover { - background: color-mix(in srgb, var(--color-accent-amber) 20%, transparent); - color: var(--color-accent-amber); + background: color-mix(in srgb, var(--color-accent) 18%, transparent); + color: var(--color-accent); +} + +/* The Orchestrator CTA — the one solid-fill blue primary button. */ +.dashboard-app-btn--primary { + border-color: transparent; + background: var(--color-accent); + color: var(--color-text-inverse); +} + +.dashboard-app-btn--primary:hover { + background: var(--color-accent-hover); + color: var(--color-text-inverse); } /* Session headline in topbar */ @@ -1929,7 +1942,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { flex-direction: column; flex-shrink: 0; height: 100%; - width: 224px; + width: var(--ao-sidebar-w, 240px); +} + +/* No width transition while actively dragging the resize handle. */ +body:not(.is-resizing-x) .sidebar-wrapper { transition: width 0.2s ease; } @@ -2399,12 +2416,93 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { background: var(--color-accent-orange); } .kanban-column__dot[data-level="merge"] { - background: var(--color-status-ready); + background: var(--color-status-merge); + box-shadow: 0 0 7px color-mix(in srgb, var(--color-status-merge) 60%, transparent); +} +.kanban-column__dot[data-level="pending"], +.kanban-column__dot[data-level="review"] { + background: var(--color-status-review); +} +.kanban-column__dot[data-level="action"] { + background: var(--color-status-attention); +} +.kanban-column__dot[data-level="working"] { + box-shadow: 0 0 7px color-mix(in srgb, var(--color-status-working) 60%, transparent); } .kanban-column__dot[data-level="done"] { background: var(--color-text-tertiary); } +/* ── Status system — one spectrum, used everywhere ─────────────────────── + Kanban card badge, sidebar dot, and session topbar pill all render from + the same StatusSpec (lib/status-spec.ts via ). Tone → color + uses the semantic --color-status-* tokens so it stays theme-correct. */ +@keyframes breathe { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0; + white-space: nowrap; + color: var(--status-tone, var(--color-text-tertiary)); +} + +.status-badge__dot { + width: 6px; + height: 6px; + flex-shrink: 0; + border-radius: 999px; + background: var(--status-tone, var(--color-text-tertiary)); +} + +.status-badge[data-breathing] .status-badge__dot { + animation: breathe 2.4s ease-in-out infinite; +} + +.status-badge[data-tone="working"] { + --status-tone: var(--color-status-working); +} +.status-badge[data-tone="input"], +.status-badge[data-tone="changes"] { + --status-tone: var(--color-status-respond); +} +.status-badge[data-tone="fail"] { + --status-tone: var(--color-status-error); +} +.status-badge[data-tone="ready"], +.status-badge[data-tone="merged"] { + --status-tone: var(--color-status-merge); +} +.status-badge[data-tone="review"], +.status-badge[data-tone="neutral"] { + --status-tone: var(--color-text-secondary); +} + +.status-badge--dot { + gap: 0; +} + +/* Pill variant — bordered tinted pill for the session topbar. */ +.status-badge--pill { + gap: 7px; + padding: 5px 11px; + border-radius: 7px; + font-size: 11.5px; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--status-tone) 25%, transparent); + background: color-mix(in srgb, var(--status-tone) 7%, transparent); +} + /* ── Activity dot / pill colors ──────────────────────────────────────── */ /* Base fallbacks for null / unknown activity states */ @@ -2992,9 +3090,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .session-card { background: var(--card-bg); border: 1px solid var(--card-border); - border-left-width: 3px; - border-left-style: solid; - border-radius: 6px; + border-radius: 7px; box-shadow: none; color: color-mix(in srgb, var(--color-text-primary) 96%, transparent); contain: layout style paint; @@ -3004,33 +3100,18 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { box-shadow 120ms ease-out; } -.session-card--accent-working { - border-left-color: var(--color-status-working); -} - -.session-card--accent-attention { - border-left-color: var(--color-status-attention); -} - -.session-card--accent-respond { - border-left-color: var(--color-status-respond); -} - -.session-card--accent-merge { - border-left-color: var(--color-status-ready); -} - +/* Status now lives in the card's StatusBadge (one status system), not a + colored left rail — the card stays a uniform hairline-ringed surface. */ +.session-card--accent-working, +.session-card--accent-attention, +.session-card--accent-respond, +.session-card--accent-merge, .session-card--accent-default { - border-left-color: var(--color-border-default); -} - -.session-card--fixed { - display: flex; - min-height: 150px; - flex-direction: column; - overflow: hidden; + border-color: var(--card-border); } +/* Compact, content-height cards (mockup) — no fixed min-height. */ +.session-card--fixed, .session-card--alert-frame, .session-card--merge-frame { display: flex; @@ -3039,14 +3120,6 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { overflow: hidden; } -.session-card--alert-frame { - min-height: 174px; -} - -.session-card--merge-frame { - min-height: 166px; -} - .dark .session-card { box-shadow: none; } @@ -3069,9 +3142,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { } .session-card:hover { - transform: translateY(-1px); + background: var(--card-hover); border-color: var(--color-border-strong); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); } .session-card:active { @@ -3092,16 +3164,16 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .session-card__header { display: flex; align-items: center; - gap: 6px; - padding: 7px 10px 3px; + gap: 8px; + padding: 12px 13px 9px; } .card__id { font-family: var(--font-mono); - font-size: 9.5px; + font-size: 10.5px; letter-spacing: 0.04em; - color: var(--color-text-muted); - flex: 1; + color: var(--color-text-tertiary); + flex-shrink: 0; } .card__adot { @@ -3159,13 +3231,14 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { } .card__title-wrap { - padding: 0 10px 4px; + padding: 0 13px 10px; } .card__title { - font-size: 12.5px; - font-weight: 600; - line-height: 1.45; + font-size: 13px; + font-weight: 500; + line-height: 1.42; + letter-spacing: -0.01em; color: var(--color-text-primary); display: -webkit-box; -webkit-box-orient: vertical; @@ -3181,19 +3254,28 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .card__meta { display: flex; align-items: center; - flex-wrap: wrap; - gap: 4px; - padding: 0 10px 5px; + gap: 6px; + padding: 0 13px 10px; +} + +.card__branch-icon { + display: inline-flex; + flex-shrink: 0; + color: var(--color-text-muted); +} + +.card__branch-icon svg { + width: 11px; + height: 11px; } .card__branch { font-family: var(--font-mono); - font-size: 9.5px; - color: var(--color-text-muted); + font-size: 11px; + color: var(--color-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - max-width: 104px; } .card__meta-sep { @@ -3203,25 +3285,16 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .card__pr { font-family: var(--font-mono); - font-size: 10px; - font-weight: 600; + font-size: 10.5px; color: var(--color-text-secondary); - background: var(--color-bg-subtle); - border: 1px solid var(--color-border-default); - padding: 1px 5px; - border-radius: 3px; text-decoration: none; white-space: nowrap; - transition: - color 80ms, - border-color 80ms, - background 80ms; + flex-shrink: 0; + transition: color 80ms; } .card__pr:hover { color: var(--color-text-primary); - border-color: var(--color-border-strong); - background: var(--color-bg-elevated); text-decoration: none; } @@ -3431,27 +3504,52 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { gap: 6px; border-top: 1px solid var(--color-border-subtle); background: transparent; - padding: 5px 10px 7px; + padding: 9px 13px 11px; } +/* Thin info footer — mockup: `PR #N · CI/review detail` (mono, faint). */ +.session-card__footer-info { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + flex: 1; + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--color-text-tertiary); +} + +.session-card__footer-detail { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-card__footer-detail[data-tone="fail"] { + color: var(--color-status-error); +} +.session-card__footer-detail[data-tone="amber"] { + color: var(--color-status-respond); +} +.session-card__footer-detail[data-tone="green"] { + color: var(--color-status-merge); +} + +/* Actions stay quiet until the card is hovered/focused — the footer reads as + information at rest (matching the mockup) and reveals controls on intent. */ .session-card__footer-actions { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; + opacity: 0; + transition: opacity 0.14s ease; } -.card__status { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 10.5px; - line-height: 1.2; - font-family: var(--font-mono); - font-weight: 400; - color: var(--color-text-muted); +.session-card:hover .session-card__footer-actions, +.session-card:focus-within .session-card__footer-actions, +.session-card__footer-actions:focus-within { + opacity: 1; } .session-card__control { @@ -5820,20 +5918,41 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { border-radius: 999px; } +/* Frameless tinted trough — one fill, no stroke. Each column carries a + faint per-column semantic top-glow. The card is the only bordered box. */ .kanban-column { display: flex; min-width: 0; flex-direction: column; - border: 1px solid var(--color-border-subtle); - border-radius: 7px; - background: var(--color-column-bg); + position: relative; + border: none; + border-radius: 13px; + background: + linear-gradient(180deg, var(--col-glow, transparent), transparent 130px), + rgba(255, 255, 255, 0.018); overflow: hidden; } +.kanban-column[data-level="working"] { + --col-glow: color-mix(in srgb, var(--color-status-working) 7%, transparent); +} +.kanban-column[data-level="action"], +.kanban-column[data-level="respond"] { + --col-glow: color-mix(in srgb, var(--color-status-attention) 6%, transparent); +} +.kanban-column[data-level="merge"] { + --col-glow: color-mix(in srgb, var(--color-status-merge) 7%, transparent); +} +.kanban-column[data-level="pending"], +.kanban-column[data-level="review"], +.kanban-column[data-level="done"] { + --col-glow: rgba(255, 255, 255, 0.02); +} + .kanban-column__header { flex-shrink: 0; - padding: 8px 10px 7px; - border-bottom: 1px solid var(--color-border-subtle); + padding: 14px 15px 11px; + border-bottom: none; } .kanban-column__title-row { @@ -8920,3 +9039,272 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { background: color-mix(in srgb, var(--color-text-tertiary) 14%, transparent); color: var(--color-text-tertiary); } + +/* ── Session detail: framed terminal + pluggable inspector rail ────────── + The terminal viewport is a live xterm.js PTY — we don't style its content; + only the frame + xterm theme object (see terminal-themes.ts). The inspector + is a registered-view slot: Summary · Changes · Browser. */ +.session-workspace { + display: flex; + min-width: 0; +} + +.session-workspace__main { + min-width: 0; +} + +.session-inspector { + position: relative; + display: flex; + width: var(--ao-inspector-w, 344px); + flex-shrink: 0; + flex-direction: column; + min-height: 0; + overflow: hidden; + background: var(--color-bg-base); + border-left: 1px solid var(--color-border-subtle); +} + +.session-inspector__tabs { + display: flex; + align-items: center; + gap: 3px; + height: 47px; + flex-shrink: 0; + padding: 0 11px; + border-bottom: 1px solid var(--color-border-subtle); +} + +.session-inspector__tab { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 0; + padding: 7px 6px; + border-radius: 7px; + font-size: 11.5px; + font-weight: 600; + color: var(--color-text-tertiary); + cursor: pointer; + transition: + background 0.12s ease, + color 0.12s ease; +} + +.session-inspector__tab:hover { + background: var(--color-bg-subtle); + color: var(--color-text-primary); +} + +.session-inspector__tab.is-active { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.session-inspector__tab-icon { + display: inline-flex; + flex-shrink: 0; +} + +.session-inspector__tab-icon svg { + width: 14px; + height: 14px; +} + +.session-inspector__tab-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-inspector__body { + flex: 1; + overflow-y: auto; + padding: 18px 18px 40px; +} + +.inspector-section { + margin-bottom: 22px; +} + +.inspector-section__head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 11px; + font-size: 10.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.09em; + color: var(--color-text-muted); +} + +.inspector-section__link { + font-size: 11px; + font-weight: 500; + text-transform: none; + letter-spacing: 0; + color: var(--color-accent); +} + +.inspector-section__link:hover { + text-decoration: underline; +} + +.inspector-empty { + font-size: 12px; + color: var(--color-text-tertiary); + line-height: 1.5; +} + +.inspector-empty--browser { + display: flex; + flex-direction: column; + align-items: center; + gap: 9px; + padding: 48px 18px; + text-align: center; +} + +.inspector-empty--browser svg { + width: 30px; + height: 30px; + color: var(--color-text-muted); +} + +.inspector-empty--browser p { + font-size: 12.5px; + color: var(--color-text-secondary); +} + +.inspector-empty--browser span { + font-size: 11.5px; + color: var(--color-text-tertiary); + line-height: 1.5; +} + +.inspector-activity { + display: flex; + flex-direction: column; + gap: 10px; +} + +.inspector-activity__summary { + font-size: 12px; + line-height: 1.5; + color: var(--color-text-secondary); +} + +.inspector-kv { + display: flex; + flex-direction: column; + gap: 1px; +} + +.inspector-kv__row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 2px; + font-size: 12.5px; +} + +.inspector-kv__k { + width: 74px; + flex-shrink: 0; + color: var(--color-text-tertiary); +} + +.inspector-kv__v { + min-width: 0; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-kv__v--mono { + font-family: var(--font-mono); + font-size: 11.5px; +} + +.inspector-diff-sum { + display: flex; + gap: 9px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-text-tertiary); + margin-bottom: 10px; +} + +.inspector-hint { + font-size: 11px; + line-height: 1.5; + color: var(--color-text-tertiary); +} + +/* ── Brand mark — blue mascot tile (the conductor) ──────────────────────── */ +.app-mark { + display: inline-grid; + place-items: center; + width: 26px; + height: 26px; + flex-shrink: 0; + border-radius: 7px; + background: #000; + overflow: hidden; + box-shadow: + inset 0 0 0 1px var(--color-border-subtle), + 0 2px 10px rgba(77, 141, 255, 0.18); +} + +.app-mark__img { + width: 23px; + height: 23px; + object-fit: contain; + image-rendering: pixelated; +} + +/* ── Resizable panel handles (sidebar + inspector) ──────────────────────── */ +.resize-handle { + position: absolute; + top: 0; + bottom: 0; + width: 7px; + z-index: 5; + cursor: col-resize; + background: transparent; + touch-action: none; +} + +.resize-handle--right { + right: -3px; +} + +.resize-handle--left { + left: -3px; +} + +.resize-handle::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + transform: translateX(-50%); + background: transparent; + transition: background 0.12s ease; +} + +.resize-handle:hover::after, +body.is-resizing-x .resize-handle::after { + background: var(--color-accent); +} + +body.is-resizing-x { + cursor: col-resize; + user-select: none; +} diff --git a/packages/web/src/app/layout.test.ts b/packages/web/src/app/layout.test.ts index 8e7b95bda..4bc7658ef 100644 --- a/packages/web/src/app/layout.test.ts +++ b/packages/web/src/app/layout.test.ts @@ -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" }, ]); }); diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 98e0547ce..62a204cd6 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -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 { export default function RootLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/packages/web/src/app/mc-board.css b/packages/web/src/app/mc-board.css new file mode 100644 index 000000000..51b45e1e0 --- /dev/null +++ b/packages/web/src/app/mc-board.css @@ -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; +} diff --git a/packages/web/src/app/mc-session.css b/packages/web/src/app/mc-session.css new file mode 100644 index 000000000..1b4dfc499 --- /dev/null +++ b/packages/web/src/app/mc-session.css @@ -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; +} diff --git a/packages/web/src/app/mc-sidebar.css b/packages/web/src/app/mc-sidebar.css new file mode 100644 index 000000000..8e32cc8d4 --- /dev/null +++ b/packages/web/src/app/mc-sidebar.css @@ -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); +} diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index a69d80e9c..202336ca7 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -258,9 +258,6 @@ function SessionPageShell({ )} -
- Agent Orchestrator -
diff --git a/packages/web/src/components/AppMark.tsx b/packages/web/src/components/AppMark.tsx new file mode 100644 index 000000000..57b6d2c0b --- /dev/null +++ b/packages/web/src/components/AppMark.tsx @@ -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 ( + + ); +} diff --git a/packages/web/src/components/AttentionZone.tsx b/packages/web/src/components/AttentionZone.tsx index 422bedd23..ee42d1504 100644 --- a/packages/web/src/components/AttentionZone.tsx +++ b/packages/web/src/components/AttentionZone.tsx @@ -9,11 +9,9 @@ import { projectSessionPath } from "@/lib/routes"; interface AttentionZoneProps { level: AttentionLevel; sessions: DashboardSession[]; - onSend?: (sessionId: string, message: string) => Promise | 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; /** 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({ ), )} @@ -184,11 +178,9 @@ function AttentionZoneView({ ))}
@@ -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"; } diff --git a/packages/web/src/components/BottomSheet.tsx b/packages/web/src/components/BottomSheet.tsx index 5f09d33d0..a402d184f 100644 --- a/packages/web/src/components/BottomSheet.tsx +++ b/packages/web/src/components/BottomSheet.tsx @@ -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); } }} > diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index dd8dc71ca..af8703828 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -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 = (
- -
- Agent Orchestrator -
+ + ) : null} {showHeaderProjectLabel ? ( <>