From c3eeecb6862051e13553743fc76841bc010e46cf Mon Sep 17 00:00:00 2001 From: prateek Date: Tue, 2 Jun 2026 05:10:55 +0530 Subject: [PATCH] merge fork, 25 May - 2 June (#2086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): add prs[] array to Session for multi-PR support — Phase 1 metadata layer * feat(core): update lifecycle-manager for multi-PR per session — Phase 2 * feat(web): add prs[] to DashboardSession and wire up multi-PR display — Phase 3 * feat(plugins): append to prs metadata field on gh pr create — Phase 4 * fix: address all code review issues — PR number, enrichment pipeline, claimPR append, stale PRs, V1 compat, SCM calls, dead code, isDraft, session validation * fix: address Greptile review — state machine aggregation, URL dedup, React keys, review comments, typecheck * fix: remove useless assignment in lifecycle-manager — lint error * fix: add missing prs field to Session objects in cli and plugin test files * fix: add prs field to integration-tests helpers and fix V1 flat-file fallback in agent-claude-code * fix: use stack approach for claimPR prs field — claimed PR becomes primary * feat(core,web): multi-PR per session — enrichment, status dots, bug fixes (#1821) - Session metadata now stores a comma-separated prs list; all PR URLs parsed into session.prs[] on load, backwards-compatible with single-PR - Enrichment loop hydrates every PR in the list each poll cycle via the existing batch GraphQL call - Fixed closed-PR filter reading from session lifecycle state instead of per-PR enrichment cache, which misclassified secondary PRs - Fixed isDraft never propagating from enrichment cache to dashboard.pr and dashboard.prs[i] - Fixed prs[0] and dashboard.pr being separate objects — enrichment on one now syncs to the other - SessionCard PR badges now show a colored status dot per PR: green (CI passing/merged), yellow (CI pending), red (CI failing/changes requested), grey (unenriched/draft/closed) - Corrected color token mapping: passing uses --color-status-merge (green), pending uses --color-status-pending (yellow) * fix(core): add missing prs field to code-review-manager test fixture * feat(web): per-PR rows with colored chips, titles, and click-to-switch on session card - Replace single-PR badges with one row per PR when session has multiple PRs - Each row shows: status-colored chip, PR title (truncated), diff size - Chip colors: green (CI passing/merged), yellow (CI pending), red (CI failing/changes requested), grey (closed/draft/unenriched) - Repo initials badge (e.g. AO, CM) shown per row when PRs span multiple repos - Clicking a row selects it — description, alerts, and action buttons in the card body all switch to that PR's context - Clicking the chip opens GitHub; clicking title/diff area selects without navigating away - Single-PR sessions are completely unchanged * fix(web): address multi-PR card review — effectivePR for merge button, color-mix chips, left-border selection * fix(web): derive prs from pr in makeSession test helper * fix(core): fall back to raw[pr] when prs absent in claimPR * feat(web): PR switcher in session detail header for multi-PR sessions * fix(core): treat ciStatus/reviewDecision 'none' as passing in multi-PR aggregation * fix(core): stale-session cleanup checks all prs before killing multi-PR session * fix(core): all-none reviewDecision aggregates to none, not approved * fix(core): prs accumulation in Node hook, bump WRAPPER_VERSION, clear stale enrichment on claimPR * fix(core,web): webhook matching for secondary PRs, restore lifecycle PR number fallback * fix(web): merge endpoint resolves target PR from session.prs for secondary PRs * fix(core,web): clamp selectedPRIndex, guard multi-PR partial cache miss, clear prs on takeover * fix(core,web): scope webhook secondary PR match by owner/repo, handle JSON metadata in Node hook * fix(web): guard merge route PR lookup by owner/repo to prevent number collision * fix(web): forward owner/repo from merge button to activate PR disambiguation guard * fix(web): propagate owner/repo through BottomSheet and AttentionZone merge triggers * test(web): update onMerge assertion to expect owner/repo arguments * fix(core,web): resolve 5 multi-PR logical gaps — conflict detection, partial cache miss, isDraft aggregation, event context prs, AttentionZone labels * fix(core,cli,plugins): add prs field to NotificationEventContext fixtures across all packages * fix(web): add owner/repo guard to primary PR branch in webhook session match * fix: handle unknown update versions and flat interactive config * ci: pin GitHub Actions to SHAs * fix: support new orchestrator from flat config * ci: skip dependency review on unsupported forks * revert: run dependency review on all PRs * docs(design): add dashboard design-language exploration + mockups Capture the design-language direction explored for the AO dashboard as live HTML mockups plus rationale, under docs/design/. - dashboard-language.md: the language — blue=orchestrator / orange=agents identity, rationed semantic color, Schibsted Grotesk + JetBrains Mono, unified status system, layout patterns, and how it diverges from DESIGN.md. - mockups/kanban.html: canonical fleet board (frameless lifecycle columns). - mockups/session.html: canonical session detail (framed xterm + pluggable inspector rail: Summary / Changes / Browser; review-comment "Address"). - mockups/{concepts,refined,*-icons}.html: exploration / icon comparisons. - mockups/mascot.png: the blue conductor mascot. Reference mockups only (CDN fonts + inline styles); production must use the Tailwind tokens in globals.css per C-02/C-07. Co-Authored-By: Claude Opus 4.7 * feat(web): migrate dashboard to mission-control design language (supersedes DESIGN.md) Replace the "Warm Terminal" system with the "Mission Control" language from the merged design exploration (docs/design/dashboard-language.md + the kanban & session mockups). One coherent system, dark control-room aesthetic, rationed color (blue = orchestrator/you, orange = working agent, amber = needs-input, red = failing/stuck, green = mergeable, else grayscale). Tokens & fonts - globals.css @theme/.dark: add literal palette (--bg #0a0b0d, --bg-side, --card #15171b = only bordered surface, --term #0c0d10, --line/--line-2, text ramp --t1..--t4, --blue/--orange/--amber/--red/--green) and re-point the existing --color-* semantic tokens to it (no token renames — alias + migrate). Flat surfaces, no warm gradients/glows. Dark theme preserved. - Self-host Schibsted Grotesk (UI) + JetBrains Mono (machine) via next/font/local — no external font CDN. Geist Sans removed. Status system (one spectrum, used everywhere) - lib/status-spec.ts (getStatusSpec) + render the kanban card badge, sidebar dot, and session topbar pill from a single source; working dot breathes (CSS-only @keyframes breathe). Fleet board (kanban.html) - Frameless tinted columns Working -> Needs you -> In review -> Ready to merge with per-column semantic top-glow; the card is the only bordered surface (hairline ring, no status left-rail). Sidebar-shows-all-projects + SSE 5s unchanged. Session detail (session.html) - Framed xterm terminal: theme object tied to tokens (orange cursor, blue selection, token-mapped ANSI) — terminal content unstyled. - New pluggable inspector rail : Summary (PR -> review comments -> Activity -> Overview) / Changes / Browser. Review comments keep the soft-blue Address action (askAgentToFix passing comment + file:line). - Topbar: shared status pill, Kill, solid-blue Orchestrator primary; the PR popover is now mobile-only (desktop has the inspector). Blue mascot mark. Tests: add status-spec / StatusBadge / SessionInspector / AppMark tests; update column-label, theme, layout-metadata and PR-location assertions; mock next/font/local in vitest. typecheck / test / lint / build all green. Co-Authored-By: Claude Opus 4.7 (1M context) * wip(web): compact card footer + cleanup before mockup-fidelity pass Co-Authored-By: Claude Opus 4.7 (1M context) * feat(web): mockup-faithful redesign pass — sidebar brand, resizable panels, cleaner chrome Addresses review feedback to follow docs/design/mockups/{kanban,session}.html closely. Sidebar (ProjectSidebar + mc-sidebar.css) - Brand + blue mascot mark moved to the TOP of the sidebar (was in the topbar). - Cool sidebar background (var(--color-bg-sidebar) #08090b) — removed the warm tint. - Removed the yellow session-count chip (plain mono count) and the Review button. - Per-project action icons now reveal on hover only. - Consolidated the footer's four buttons into a single Settings gear + popover (show killed / show done / show session id / theme). - Right edge is drag-resizable. Board (Dashboard + AttentionZone + SessionCard + mc-board.css) - Topbar brand removed; "Board" header + mockup subtitle; blue primary CTA. - Frameless tinted columns with per-column glow; compact informational cards (status badge · id · hover terminal, 2-line title, branch w/ git icon, thin PR/CI footer). Removed inline quick-reply/alerts/CI-chips/merge+review buttons from cards (actions live on the session page); kept hover kill + terminal link. - Split Done card into SessionCard.parts.tsx (≤400 lines). Session detail (SessionDetail* + SessionInspector + TerminalControls + mc-session.css) - Topbar: ‹ Kanban back button, title with branch to its RIGHT (git icon, no odd truncation), shared status pill, Kill, blue Orchestrator. Brand removed from the session shell header too. - Terminal header: removed the "Connected" status text; matches the mockup (Terminal label · id · zoom · fullscreen). Terminal flush (no stray left margin). - Terminal head and inspector tab bar are both 47px so their rules align. - Inspector: PR → review comments → Activity timeline → Overview; left edge drag-resizable. Split header helpers into SessionDetailHeader.parts.tsx. Shared infra - useResizable hook (pointer drag → CSS var on :root, localStorage-persisted, no inline style); .resize-handle styles; per-screen mc-*.css loaded after globals.css in layout.tsx; sidebar/inspector widths read --ao-sidebar-w / --ao-inspector-w with sensible min/max + double-click reset. typecheck / web tests (1005) / lint (0 errors) / build all green; resize verified. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(web): sidebar/topbar polish from review feedback - Notification bell icon 13→17px (was tiny inside its 34px button). - Sidebar project rows: count shows at rest in the right slot; per-project action icons are now an absolutely-positioned hover-reveal group (out of flow) so the project name keeps full width and the row stays single-height (fixes the inflated rows / vertically-stacked icons). - Session status dot nudged 1px to vertically center against the mono label. - Orchestrator session page no longer renders the inspector rail (full-width terminal — nothing to inspect). typecheck / web tests (1005) / lint (0 errors) green. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(web): notification icon size + sidebar/topbar review fixes - Notification bell on the session topbar was squeezed to a 6px sliver: the text-button padding rule applied to the icon-only button inside its fixed 34px width. Give the bell its own 34px square / zero-padding rule → 17px icon. - Remove the redundant sidebar toggle from the session topbar on desktop (mobile keeps the drawer hamburger); the sidebar owns collapse/expand. - Collapsed sidebar now shows an expand button so it can be reopened. - Trim sidebar padding + session-list indent so project/agent rows get more width. typecheck / web tests (1005) / lint (0 errors) green; collapse↔expand + icon size verified. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(web): sidebar session names truncate with ellipsis; rename pencil no longer overlays - The session label was an inline , so text-overflow:ellipsis never applied — long names hard-clipped. Make it display:block so it ellipsizes. - The rename pencil now absolutely positions at the row's right edge; at rest the label gets the full row width, and on hover the link gains right-padding so the text re-truncates and the pencil sits in the reserved space instead of covering the name. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(web): project name reserves space for hover action icons (no overlap) On hover the per-project action icons reveal over the right slot; the toggle now gains right-padding so the project name re-truncates with ellipsis instead of running underneath the icons — same pattern as the session rows. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(web): remove kanban topbar toggle + redesign empty state to mission-control - The board (kanban) topbar still rendered a desktop sidebar toggle; make it mobile-only (the sidebar owns collapse/expand), matching the session topbar. - Empty state (Skeleton EmptyState): ghost columns now use the live board's labels (Working / Needs you / In review / Ready to merge), 4 columns, and the frameless tinted-trough look — removing the old dashed/warm-tinted columns and the stale 5-category set. Orchestrator glyph recolored to the blue accent via tokens (was hardcoded orange). typecheck / web tests (1005) / lint (0 errors) green. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(web): show project name in titlebar, not the raw project id hash The board topbar special-cased projects named 'Agent Orchestrator' to display the project id (e.g. agent-orchestrator_649ba24578) — unreadable gibberish. Always show the human-readable project name instead. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(core,web): detectPR branch guard, multi-PR prLabel aggregation, reset selectedPRIndex on session change * fix(web): render terminal with xterm WebGL renderer (fixes broken box-drawing) The dashboard terminal used xterm's default DOM renderer, which draws each row as a separate DOM line and cannot tile box-drawing / block glyphs across cells. Agent TUIs (Claude Code's bordered panels, the "N shell command" expansion) rendered with broken/gappy frames and a grey blob — independent of font or lineHeight (verified: even a complete font at lineHeight 1.0 breaks in the DOM renderer). Load xterm's WebGL renderer (@xterm/addon-webgl), which GPU-draws box/block glyphs into each cell so frames connect and shaded regions stay solid. Loaded rAF-deferred after open() with a DOM fallback on context-loss / when WebGL is unavailable (headless, blocklisted GPU). This matches how superset renders the same agent TUIs. The WebGL addon only ships for the xterm 6.1.0-beta train, so bump @xterm/xterm 6.0.0 -> 6.1.0-beta.256 and the addons to the matched beta set (the same pair superset runs in production). Also drops the earlier no-op fontWeight/fontWeightBold/letterSpacing change — those values equalled xterm's defaults and had no visible effect. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): enforce dark-mode contrast floor so agent blocks aren't grey blobs Claude Code's expanded "Ran N shell command" block is painted on an ANSI white background (ESC[47m). The terminal theme sets `white` ≈ `foreground` (#c5ccd3), and dark mode used `minimumContrastRatio: 1` (no floor), so the block's text was the same colour as its background — rendering as an unreadable grey blob (only independently-coloured links/emoji showed through). Raise the dark-mode floor to 4.5 (WCAG AA), matching the rationale already used for light mode (7). xterm then auto-adjusts only the foregrounds that fail the floor, leaving all other agent colours untouched. Verified in an isolated harness with the real theme: white-on-white block goes from invisible to legible. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): load Unicode 11 addon so emoji/wide-char widths match tmux Agent TUIs and tmux lay out tables treating emoji like ✅/❌ as 2 cells wide (modern Unicode). xterm defaults to Unicode 6 width tables, where those are 1 cell — so the grid shifts a column after every emoji, breaking table borders and leaving stray text fragments (visible only in the web terminal; a direct tmux attach via a modern terminal renders it fine). Load @xterm/addon-unicode11 and set unicode.activeVersion = "11" so xterm's width tables agree with tmux/the agent. Same beta train as the other addons. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): rescale overlapping glyphs so out-of-font chars don't overlap text JetBrains Mono is subset to Latin glyphs, so characters it lacks (arrows → ← ↔, CJK, emoji, powerline) are drawn from a fallback font whose advance can exceed our monospace cell — the glyph then bleeds into the next cell and overlaps the following character. Set rescaleOverlappingGlyphs so xterm shrinks any glyph wider than its cell back to fit. Fixes the whole class of out-of-font overflow, not just arrows. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(web): ship full-coverage JetBrains Mono (restore arrows/box/blocks) The self-hosted JetBrains Mono was subset to ~229 Latin glyphs, dropping arrows (→ ← ↔), box-drawing, block elements and other symbols agents emit. Those fell back to a system font at the wrong cell width — overlapping (pre-rescale) or shrunk/blurry (post-rescale). Replace it with the full JetBrains Mono variable face (OFL-1.1, wght 100–800, ~111 KB woff2, same metrics) so those glyphs render in-font, full-size and crisp at the correct monospace width. rescaleOverlappingGlyphs stays as a safety net for the few glyphs even the full face lacks (CJK, emoji, powerline PUA). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Laraib-1629 Co-authored-by: suraj-markup Co-authored-by: itrytoohard Co-authored-by: Claude Opus 4.7 Co-authored-by: Priyanshu Choudhary <57816400+Priyanchew@users.noreply.github.com> --- .github/workflows/canary.yml | 6 +- .github/workflows/ci.yml | 24 +- .github/workflows/coverage.yml | 6 +- .github/workflows/deploy-vps.yml | 2 +- .github/workflows/integration-tests.yml | 6 +- .github/workflows/onboarding-test.yml | 6 +- .github/workflows/release.yml | 8 +- .github/workflows/security.yml | 12 +- DESIGN.md | 467 +++---- docs/design/dashboard-language.md | 129 ++ docs/design/mockups/address-icons.html | 51 + docs/design/mockups/concepts.html | 356 +++++ docs/design/mockups/kanban.html | 248 ++++ docs/design/mockups/mascot.png | Bin 0 -> 18221 bytes docs/design/mockups/orchestrator-icons.html | 126 ++ docs/design/mockups/orgchart-icons.html | 59 + docs/design/mockups/refined.html | 270 ++++ docs/design/mockups/session.html | 428 ++++++ packages/cli/__tests__/commands/start.test.ts | 133 ++ .../cli/__tests__/lib/update-check.test.ts | 67 + packages/cli/src/commands/start.ts | 120 +- packages/cli/src/commands/status.ts | 1 + packages/cli/src/lib/notify-test.ts | 13 + packages/cli/src/lib/update-check.ts | 12 +- .../__tests__/agent-workspace-hooks.test.ts | 4 +- .../src/__tests__/code-review-manager.test.ts | 12 + .../__tests__/dashboard-notifications.test.ts | 12 + .../src/__tests__/lifecycle-manager.test.ts | 181 ++- .../src/__tests__/notification-data.test.ts | 12 + .../__tests__/session-from-metadata.test.ts | 126 ++ .../session-manager/claim-pr.test.ts | 109 ++ packages/core/src/__tests__/test-utils.ts | 2 + packages/core/src/agent-workspace-hooks.ts | 49 +- packages/core/src/lifecycle-manager.ts | 343 ++++- packages/core/src/notification-data.ts | 2 + packages/core/src/recovery/validator.ts | 1 + packages/core/src/session-manager.ts | 47 +- packages/core/src/types.ts | 5 + packages/core/src/update-cache.ts | 14 +- .../core/src/utils/session-from-metadata.ts | 45 +- .../src/helpers/event-factory.ts | 1 + .../src/helpers/session-factory.ts | 1 + .../plugins/agent-aider/src/index.test.ts | 1 + .../src/__tests__/activity-detection.test.ts | 1 + .../agent-claude-code/src/index.test.ts | 1 + .../plugins/agent-claude-code/src/index.ts | 43 + .../plugins/agent-codex/src/index.test.ts | 1 + .../plugins/agent-cursor/src/index.test.ts | 1 + .../plugins/agent-kimicode/src/index.test.ts | 1 + .../plugins/agent-opencode/src/index.test.ts | 1 + .../notifier-dashboard/src/index.test.ts | 12 + .../notifier-openclaw/src/index.test.ts | 12 + .../plugins/scm-github/test/index.test.ts | 1 + .../plugins/scm-gitlab/test/index.test.ts | 1 + .../plugins/terminal-iterm2/src/index.test.ts | 1 + .../plugins/terminal-web/src/index.test.ts | 1 + packages/web/package.json | 8 +- packages/web/public/mascot.png | Bin 0 -> 18221 bytes .../__tests__/activity-events-routes.test.ts | 1 + packages/web/src/__tests__/api-routes.test.ts | 1 + .../web/src/__tests__/components.test.tsx | 300 +---- packages/web/src/__tests__/helpers.ts | 1 + packages/web/src/__tests__/next-font-mock.ts | 12 + .../web/src/__tests__/prompt-spawn.test.ts | 1 + .../web/src/app/api/prs/[id]/merge/route.ts | 24 +- packages/web/src/app/api/sessions/route.ts | 3 +- packages/web/src/app/globals.css | 882 ++++++++---- packages/web/src/app/layout.test.ts | 2 +- packages/web/src/app/layout.tsx | 14 +- packages/web/src/app/mc-board.css | 289 ++++ packages/web/src/app/mc-session.css | 301 +++++ packages/web/src/app/mc-sidebar.css | 432 ++++++ packages/web/src/app/sessions/[id]/page.tsx | 3 - packages/web/src/components/AppMark.tsx | 21 + packages/web/src/components/AttentionZone.tsx | 36 +- packages/web/src/components/BottomSheet.tsx | 4 +- packages/web/src/components/Dashboard.tsx | 124 +- .../DashboardNotificationButton.tsx | 6 +- .../web/src/components/ProjectSidebar.tsx | 238 ++-- .../web/src/components/SessionCard.parts.tsx | 336 +++++ packages/web/src/components/SessionCard.tsx | 1189 ++++------------- packages/web/src/components/SessionDetail.tsx | 27 +- .../components/SessionDetailHeader.parts.tsx | 135 ++ .../src/components/SessionDetailHeader.tsx | 257 ++-- .../src/components/SessionEndedSummary.tsx | 50 +- .../web/src/components/SessionInspector.tsx | 311 +++++ packages/web/src/components/Skeleton.tsx | 10 +- packages/web/src/components/StatusBadge.tsx | 44 + .../src/components/__tests__/AppMark.test.tsx | 14 + .../__tests__/Dashboard.kanbanLayout.test.tsx | 8 +- .../__tests__/DirectTerminal.render.test.tsx | 72 +- .../__tests__/DirectTerminal.test.ts | 4 +- .../__tests__/SessionCard.coverage.test.tsx | 15 +- .../__tests__/SessionDetail.desktop.test.tsx | 29 +- ...essionDetail.mergeConflictActions.test.tsx | 6 +- .../__tests__/SessionInspector.test.tsx | 49 + .../components/__tests__/StatusBadge.test.tsx | 49 + .../components/terminal/TerminalControls.tsx | 87 +- .../components/terminal/terminal-themes.ts | 52 +- .../components/terminal/useXtermTerminal.ts | 62 +- packages/web/src/fonts/JetBrainsMono.woff2 | Bin 0 -> 113608 bytes packages/web/src/fonts/SchibstedGrotesk.woff2 | Bin 0 -> 46864 bytes packages/web/src/fonts/fonts.ts | 29 + packages/web/src/hooks/useResizable.ts | 84 ++ .../web/src/lib/__tests__/serialize.test.ts | 4 + .../web/src/lib/__tests__/status-spec.test.ts | 84 ++ packages/web/src/lib/cache.ts | 1 + packages/web/src/lib/scm-webhooks.test.ts | 24 + packages/web/src/lib/scm-webhooks.ts | 5 +- packages/web/src/lib/serialize.ts | 39 + packages/web/src/lib/status-spec.ts | 102 ++ packages/web/src/lib/types.ts | 4 + packages/web/vitest.config.ts | 4 + pnpm-lock.yaml | 62 +- scripts/qa-setup-test-repo.sh | 190 +++ scripts/qa-switch-branch.sh | 127 ++ 116 files changed, 7430 insertions(+), 2432 deletions(-) create mode 100644 docs/design/dashboard-language.md create mode 100644 docs/design/mockups/address-icons.html create mode 100644 docs/design/mockups/concepts.html create mode 100644 docs/design/mockups/kanban.html create mode 100644 docs/design/mockups/mascot.png create mode 100644 docs/design/mockups/orchestrator-icons.html create mode 100644 docs/design/mockups/orgchart-icons.html create mode 100644 docs/design/mockups/refined.html create mode 100644 docs/design/mockups/session.html create mode 100644 packages/core/src/__tests__/session-from-metadata.test.ts create mode 100644 packages/web/public/mascot.png create mode 100644 packages/web/src/__tests__/next-font-mock.ts create mode 100644 packages/web/src/app/mc-board.css create mode 100644 packages/web/src/app/mc-session.css create mode 100644 packages/web/src/app/mc-sidebar.css create mode 100644 packages/web/src/components/AppMark.tsx create mode 100644 packages/web/src/components/SessionCard.parts.tsx create mode 100644 packages/web/src/components/SessionDetailHeader.parts.tsx create mode 100644 packages/web/src/components/SessionInspector.tsx create mode 100644 packages/web/src/components/StatusBadge.tsx create mode 100644 packages/web/src/components/__tests__/AppMark.test.tsx create mode 100644 packages/web/src/components/__tests__/SessionInspector.test.tsx create mode 100644 packages/web/src/components/__tests__/StatusBadge.test.tsx create mode 100644 packages/web/src/fonts/JetBrainsMono.woff2 create mode 100644 packages/web/src/fonts/SchibstedGrotesk.woff2 create mode 100644 packages/web/src/fonts/fonts.ts create mode 100644 packages/web/src/hooks/useResizable.ts create mode 100644 packages/web/src/lib/__tests__/status-spec.test.ts create mode 100644 packages/web/src/lib/status-spec.ts create mode 100755 scripts/qa-setup-test-repo.sh create mode 100755 scripts/qa-switch-branch.sh 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 0000000000000000000000000000000000000000..7eff2e3f5a2f2865dc71a2a8ac710fb66a34ce55 GIT binary patch literal 18221 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 A;s5{u literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7eff2e3f5a2f2865dc71a2a8ac710fb66a34ce55 GIT binary patch literal 18221 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 A;s5{u literal 0 HcmV?d00001 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 ? ( <>