style(design): design review fixes + fresh Warm Terminal design system (#927)
* style(design): FINDING-001 — add prefers-reduced-motion support All animations and transitions are disabled when the user's system requests reduced motion, per DESIGN.md accessibility requirements. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(design): FINDING-003 — remove concurrent breathe animations Status pills had two animations: a breathe animation on the pill and a dot-pulse on the child dot. DESIGN.md says "one animation per element, one purpose" and "keep dot pulse, remove border heartbeat." Removed all three breathe keyframes, kept dot-pulse only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(design): FINDING-004 — fix dashboard title weight and tracking DESIGN.md specifies display headings at weight 680 and letter-spacing -0.035em. The dashboard title was using 600 / -0.05em. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(design): FINDING-005 — fix detail-card text to blue-tinted graphite Detail cards overrode text-secondary and text-tertiary with neutral grays (#9898a0, #5c5c66). DESIGN.md specifies blue-tinted graphite palette (#a5afc4, #6f7c94) for dark mode text. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(design): FINDING-008 — add text-wrap: balance on headings Dashboard title and kanban column titles now use text-wrap: balance for more even line breaks on narrow viewports. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(design): FINDING-006/009 — fix section label semantics and spacing Changed "Attention Board" from <h2> to <div role="heading"> since it's styled as a 12px uppercase label, not a heading. Also fixed letter-spacing from 0.16em to 0.06em per DESIGN.md UI/Labels spec. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style(design): FINDING-007 — contextual empty state messages Empty kanban columns now show context-specific messages instead of generic "No sessions" text. Each column's empty state reflects its purpose: "No agents need your input" (Respond), "No code waiting for review" (Review), etc. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(design): fresh design system — Warm Terminal Complete redesign from Industrial Precision (blue-tinted) to Warm Terminal (brown-tinted). Key changes: - Warm charcoal surfaces (#121110, #1a1918, #222120) replace blue-gray - Cream text (#f0ece8) replaces blue-white (#eef3ff) - Warm periwinkle accent (#8b9cf7) replaces cool blue (#5B7EF8) - Berkeley Mono for display headlines (mono cohesion) - Added: Accessibility section (44px touch targets, WCAG AA, focus-visible) - Added: Component anatomy (button states, card structure, input fields) - Added: Light mode design rationale (warm parchment, not clinical white) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(design): swap Berkeley Mono for JetBrains Mono (free) Berkeley Mono is a paid font ($75). JetBrains Mono is free, open source, already loaded in the project, and the mono-for-headlines concept works the same way with it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(design): fix light mode contrast failures Light mode text-tertiary #a8a29e failed WCAG AA at 2.5:1 on white. Darkened to #736e6b (5.0:1). Light mode accent #6b73c4 was borderline at 4.3:1, darkened to #5c64b5 (5.3:1). All pairs now pass AA. Added verified contrast ratios for both modes to accessibility section. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: gitignore .gstack/ directory Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(design): add design audit report and screenshots Design review audit report with before/after screenshots for all dashboard pages (kanban, session detail, PRs) across desktop, tablet, and mobile viewports in both light and dark mode. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(design): address PR review comments - Fix mobile test expecting removed "No sessions" text. The merge zone emptyMessage is now "Nothing cleared to land yet." (Bugbot comment #1) - Remove no-op .dark .detail-card override that duplicated global dark values after FINDING-005 fix aligned them (Bugbot comment #2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: gitignore .gstack-report/ and remove from tracking The .gstack-report/ directory contains local audit artifacts with filesystem paths. Should not be tracked in the repository. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(design): align dashboard title CSS to new DESIGN.md spec Dashboard title was using old Geist Sans values (weight 680, -0.035em). New spec is JetBrains Mono, weight 500, letter-spacing -0.02em. Added font-family: var(--font-mono) to match. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use native h2 element for Attention Board section heading Replace ARIA role="heading" div with semantic h2 per ARIA first rule — native elements are preferred over ARIA roles for actual headings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: pre-landing review fixes — a11y, dead code, test coverage - Add aria-controls + id to accordion button/body pair in AttentionZone - Wrap empty-state messages in aria-live="polite" regions for AT announcements - Remove dead message prop and isDefault from EmptyState (Skeleton.tsx) - Add parameterized test covering all 6 zone-specific empty messages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: revert aria-live on empty states — causes false AT announcements Codex review identified that role="status" aria-live on static empty-state text causes burst announcements on page load (all empty columns fire) and announces in collapsed mobile sections that aren't visible. Empty states are static text, not dynamic transitions. The aria-controls fix is kept. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: bump version and changelog (v0.0.1.0) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove .gstack-report/ from .gitignore * chore: remove VERSION and CHANGELOG (not used in this project) * style(design): warm terminal color migration + inline style removal Migrate all CSS tokens from cool blue-tinted graphite to warm brown-tinted terminal aesthetic per DESIGN.md spec. Replace inline style color mappings in ActivityDot, AttentionZone, Dashboard, ProjectSidebar, and SessionCard with data-attribute CSS selectors. Fix duplicate className bug on SessionCard done-title element. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: pre-landing review fixes — activity dot fallback + review stat color Add base CSS fallback for activity-dot, activity-pill, and activity-pill__text so null/unknown activity states render visibly (gray) instead of invisible. Fix review stat card to use accent-orange (matching kanban/sidebar/mobile review indicators) instead of cyan. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: checkpoint current design branch state * design changes * feat(web): redesign session detail page — compact PR card, identity strip, layout reorder - Redesign SessionTopStrip with simplified breadcrumbs, action buttons (Message/Kill) - Replace stacked PR card with compact inline layout: title row + blocker/CI chips + collapsible comments - Move PR card above terminal for better information hierarchy - Replace vertical IssuesList with inline buildBlockerChips helper - Add ~200 lines of new CSS classes for compact PR card design system - Add changedFiles field to DashboardPR type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(web): design system tokens, sidebar redesign, and component primitives - Align all color tokens (status, bg, border, text) across three HTML mockups - Rewrite ProjectSidebar to match finalized.html: rotation chevron, session status text, border-bottom project separators, 224px width - Add packages/web/DESIGN.md: agent-readable reference for tokens, typography, component patterns, anti-patterns - Add Badge.tsx: generic badge/chip/pill primitive with status/outline/default variants - Add Button.tsx: ghost/primary/danger button primitive - Update CLAUDE.md to reference DESIGN.md as required pre-read for web UI work Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style(design): FINDING-001 — card border-radius 0 → 6px to match mockup * style(design): FINDING-002 — column border-radius 0 → 7px, border subtle to match mockup * style(design): FINDING-003 — column header mono font, 500 weight, muted color to match mockup * style(design): FINDING-004/005/006 — fix accent-blue/yellow/purple tokens to match mockup * style(design): FINDING-007 — add --color-bg-card token (light #fff, dark #1c1b19) * Refine dashboard design system and remove fixture flow * Fix respond status colors in dashboard indicators * Align tests with updated dashboard and metadata behavior * fix(core): register notifier aliases consistently * chore(web): drop uncovered showcase routes * Add desktop PullRequestsPage coverage tests * Remove generated coverage artifact * Consolidate web design guidance into the root design system * Fix working and ready status color tokens * Fix sidebar collapse and inline kill confirmation * fix(qa): ISSUE-001 - show all mobile filter chips * Restore full title contrast in session cards * Fix review feedback in dashboard state styling * style: implement mobile responsive designs (feed, terminal-first, dense PRs) Dashboard: replace accordion with urgency-sorted priority feed, horizontal scroll filter pills. Session Detail: terminal-first layout with floating header, status pill, PR bottom sheet. PRs: dense rows with CI dots, grouped sections, muted merged/closed rows. Update tests to match new mobile layouts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix mobile terminal padding with PR sheet layout * Polish mobile feed and session detail styling * Align mobile dashboard layouts with gstack designs * Fix mobile terminal actions and PR review labels --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2ebe111af0
commit
af2af115bd
|
|
@ -2,6 +2,7 @@ node_modules/
|
|||
dist/
|
||||
.pnpm-store/
|
||||
.next/
|
||||
.next-dev/
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
*.patch
|
||||
|
|
|
|||
|
|
@ -201,7 +201,8 @@ Hash = SHA-256 of config directory (first 12 chars). Prevents collision across m
|
|||
| `packages/web/src/components/SessionCard.tsx` | Kanban session card |
|
||||
| `packages/web/src/hooks/useSessionEvents.ts` | SSE consumer hook |
|
||||
| `packages/web/src/lib/types.ts` | Dashboard types |
|
||||
| `packages/web/src/app/globals.css` | Design tokens and base styles |
|
||||
| `packages/web/src/app/globals.css` | Design tokens and base styles (full token definitions) |
|
||||
| `DESIGN.md` | **Design system reference** — design principles, token mapping, component patterns, anti-patterns (read this before writing any web UI) |
|
||||
| `agent-orchestrator.yaml` | Project-level config (user-created) |
|
||||
| `eslint.config.js` | ESLint flat config |
|
||||
| `tsconfig.base.json` | Shared TypeScript base config |
|
||||
|
|
|
|||
290
DESIGN.md
290
DESIGN.md
|
|
@ -3,17 +3,17 @@
|
|||
## 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, Emdash. All are native Mac apps. Agent Orchestrator is the web-based alternative.
|
||||
- **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.
|
||||
|
||||
## Aesthetic Direction
|
||||
- **Direction:** Industrial Precision
|
||||
- **Decoration level:** Intentional — subtle depth through surface hierarchy, ambient glow on active states, gradient surfaces in dark mode. No decorative blobs, no gratuitous gradients.
|
||||
- **Mood:** Trading terminal meets control room. Dense, scannable, utilitarian, with just enough warmth that developers want to live in it for 10 hours. "I'm running an operation" not "I'm organizing tasks."
|
||||
- **Reference sites:** Conductor.build, t3.codes, openai.com/codex, emdash.dev
|
||||
- **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)
|
||||
|
||||
## Typography
|
||||
- **Display/Hero:** Geist Sans, weight 680, letter-spacing -0.035em — same font as body but differentiated through weight and tracking. Tighter and heavier creates display hierarchy without a font swap. No cognitive gear-shifts on a scan-heavy dashboard.
|
||||
- **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.
|
||||
|
|
@ -28,62 +28,66 @@
|
|||
- display: clamp(22px, 2.8vw, 32px) (hero headings)
|
||||
|
||||
## Color
|
||||
- **Approach:** Restrained with signal accents. Color is a priority channel, not decoration.
|
||||
- **Accent (cool):** #5B7EF8 — interactive elements, links, focus rings. Used sparingly.
|
||||
- **Accent hover:** #7a96ff
|
||||
- **Accent tint:** rgba(91, 126, 248, 0.12)
|
||||
- **Attention (warm):** #f1be64 — states requiring human input. Amber is universally "needs attention" without the panic of red.
|
||||
- **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.
|
||||
|
||||
### Surfaces (Dark Mode)
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| bg-base | #0a0d12 | Page background |
|
||||
| bg-surface | #11161d | Card/column backgrounds |
|
||||
| bg-elevated | #171d26 | Modals, popovers, hover states |
|
||||
| bg-elevated-hover | #1c2430 | Hover on elevated surfaces |
|
||||
| bg-subtle | rgba(177, 206, 255, 0.05) | Subtle tints, pill backgrounds |
|
||||
| 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. |
|
||||
|
||||
### Surfaces (Light Mode)
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| bg-base | #f5f5f7 | Page background |
|
||||
| bg-surface | #ffffff | Card/column backgrounds |
|
||||
| bg-elevated | #ffffff | Modals, popovers |
|
||||
| bg-elevated-hover | #f7f7f8 | Hover states |
|
||||
| bg-subtle | #f0f0f2 | Subtle tints |
|
||||
| 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. |
|
||||
|
||||
**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.
|
||||
|
||||
### Text (Dark Mode)
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| text-primary | #eef3ff | Headings, card titles, body. Blue-white, not pure white. |
|
||||
| text-secondary | #a5afc4 | Descriptions, metadata. Readable in dense layouts. |
|
||||
| text-tertiary | #6f7c94 | Timestamps, placeholders, disabled states. |
|
||||
| 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. |
|
||||
|
||||
### Text (Light Mode)
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| text-primary | #1b1b1f | Headings, card titles, body. |
|
||||
| text-secondary | #5e5e66 | Descriptions, metadata. |
|
||||
| text-tertiary | #8e8e96 | Timestamps, placeholders. |
|
||||
| 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). |
|
||||
|
||||
### Borders (Dark Mode)
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| border-subtle | rgba(160, 190, 255, 0.08) | Dividers, section separators |
|
||||
| border-default | rgba(160, 190, 255, 0.14) | Card edges, input borders |
|
||||
| border-strong | rgba(185, 214, 255, 0.24) | Hover states, focus indicators |
|
||||
| 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. |
|
||||
|
||||
### Status Colors
|
||||
| Status | Dark Mode | Light Mode | Usage |
|
||||
|--------|-----------|------------|-------|
|
||||
| Working | #22c55e | #16a34a | Agent actively coding. Green dot with pulse ring animation. |
|
||||
| Ready | #5B7EF8 | #5e6ad2 | Queued, awaiting start or CI pending. |
|
||||
| Respond | #f1be64 | #ca8a04 | Needs human input. Amber = attention without panic. |
|
||||
| Review | #06b6d4 | #0891b2 | Code ready for review. Cyan = "look when ready." |
|
||||
| Error | #ef4444 | #dc2626 | CI failed, agent crashed. Red = broken. |
|
||||
| Done | #3a4252 | #d0d7de | Completed. Fades to secondary text. Done items recede. |
|
||||
| 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. |
|
||||
|
||||
- **Dark mode strategy:** Blue-tinted graphite palette (not neutral 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.04)`. Subtle radial gradients on body for ambient depth.
|
||||
**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.
|
||||
|
||||
- **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.
|
||||
|
||||
## Spacing
|
||||
- **Base unit:** 4px
|
||||
|
|
@ -96,12 +100,9 @@
|
|||
- **Mobile column order:** Respond > Review > Pending > Working (urgency-first)
|
||||
- **Max content width:** 1280px for settings/detail pages
|
||||
- **Border radius:**
|
||||
- base: 2px (cards, buttons, inputs — consistent, sharp, intentional)
|
||||
- sm: 4px (tooltips, small transient elements)
|
||||
- md: 6px (dropdowns, floating interactive elements)
|
||||
- lg: 8px (modals, large floating overlays)
|
||||
- full: 9999px (pills, badges, count indicators)
|
||||
- **Card inset highlight:** `inset 0 1px 0 rgba(255,255,255,0.04)` in dark mode
|
||||
- 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
|
||||
|
|
@ -126,6 +127,167 @@
|
|||
- 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.
|
||||
|
||||
## 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]`.
|
||||
|
||||
## Token Mapping
|
||||
These are the concrete token names used in `packages/web/src/app/globals.css`. New UI code should reference these names directly.
|
||||
|
||||
### 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`.
|
||||
|
|
@ -143,15 +305,29 @@
|
|||
- `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
|
||||
|
||||
## 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 (primary, Codex CLI, Claude subagent, Emil Kowalski design eng) |
|
||||
| 2026-03-28 | Geist Sans + JetBrains Mono (2 fonts only) | Emil review: 4 fonts creates cognitive gear-shifts on scan-heavy dashboards. Two fonts, hierarchy through weight + tracking. |
|
||||
| 2026-03-28 | Accent #5B7EF8 instead of Tailwind blue-500 | Emil review: stock Tailwind blue reads "default" and undermines Industrial Precision identity. Existing token-reference blue is more sophisticated. |
|
||||
| 2026-03-28 | Dual accent (cool blue + warm amber) | All 3 outside voices independently proposed amber for attention states. Two accents with clear semantic roles beat a single color. |
|
||||
| 2026-03-28 | Fixed-width kanban columns (not variable) | Emil review: variable width breaks spatial memory. Encode urgency in column header dots instead. |
|
||||
| 2026-03-28 | 2px base border-radius | Full 0px risks looking unstyled. 2px reads as intentionally sharp while feeling designed. Consistent across cards, buttons, inputs. Distinct from competitors' 8-12px rounded corners. |
|
||||
| 2026-03-28 | Keep dot pulse, remove border heartbeat | Emil review: 4s border animation on 15+ cards is "decorative anxiety" with high perf cost. Existing 8px dot pulse is correct pattern — small surface, GPU-composited. |
|
||||
| 2026-03-28 | Simplify ready-to-merge to single animation | Emil review: 3 concurrent keyframe animations on merge-ready cards is over-engineered. One animation per element, one purpose. Keep dot pulse only. |
|
||||
| 2026-03-28 | Initial design system created | Created by /design-consultation with competitive research (Conductor.build, T3 Code, OpenAI Codex, Emdash) + 4 design voices |
|
||||
| 2026-03-28 | Geist Sans + JetBrains Mono (2 fonts only) | Emil review: 4 fonts creates cognitive gear-shifts on scan-heavy dashboards |
|
||||
| 2026-03-28 | 2px base border-radius (v1) | Full 0px risks looking unstyled. 2px reads as intentionally sharp while feeling designed. |
|
||||
| 2026-04-05 | 0px border-radius everywhere | Hard edges are the identity. With warm surfaces and inset highlights providing depth, rounding adds nothing. Zero radius is the most honest expression of Industrial/Warm Terminal. |
|
||||
| 2026-03-28 | Keep dot pulse, remove border heartbeat | Emil review: 4s border animation on 15+ cards is "decorative anxiety" with high perf cost. |
|
||||
| 2026-04-05 | Fresh design system: Warm Terminal | Every competitor converges on cool blue-gray. Warm charcoal with cream text and warm periwinkle accent creates instant visual distinction. |
|
||||
| 2026-04-05 | JetBrains Mono for display + data | Mono headlines in a mono-heavy dashboard create typographic cohesion instead of two competing voices. Free, open source, already in the codebase. |
|
||||
| 2026-04-05 | Warm periwinkle #8b9cf7 accent (not gold) | Gold collides semantically with amber attention state. Blue = clickable is muscle memory. Warm periwinkle fits the palette without signal confusion. |
|
||||
| 2026-04-05 | Brown-tinted surfaces, not neutral or blue-tinted | #121110 / #1a1918 / #222120 — warm undertone sets AO apart from every Linear clone. Light mode uses warm parchment #f5f3f0. |
|
||||
| 2026-04-05 | Added accessibility section | Missing from v1. Touch targets 44px min, WCAG AA contrast, focus-visible, prefers-reduced-motion. |
|
||||
| 2026-04-05 | Added component anatomy section | Missing from v1. Button states, input states, card structure, status pill, alert anatomy. |
|
||||
| 2026-04-05 | Added light mode rationale | v1 listed values without explaining why. Warm parchment base, white card float, desaturated accent. |
|
||||
| 2026-04-07 | `--status-respond` is amber, never red | Critique found Respond column using `--status-error` (red). Respond = human decision needed, not error. Separate token. See status colors table. |
|
||||
| 2026-04-07 | Two-stage delete confirmation pattern | P0 safety: trash button first click enters amber "kill?" state for 2s; second click confirms. No modal. In-place via CSS `::after` + JS class toggle. Prevents accidental agent termination. |
|
||||
| 2026-04-07 | Working card titles at full weight | P2: Working state is the primary operational state. Never dim active card titles. Dimming is reserved for Done/archived cards only. |
|
||||
| 2026-04-07 | No current-project group label in sidebar sessions | P2: Current project label in the sessions list is redundant — the project switcher chip 12px above already names the project. Only other projects need group labels. |
|
||||
| 2026-04-07 | Remove column shadows | P3: `18px/42px` column box-shadow created competing depth layers with card shadows. Border + background contrast does separation. No column-level shadow needed. |
|
||||
| 2026-04-07 | Topbar shows page name only, not project name | Minor: Topbar "vinesight-rn / kanban" duplicated project name visible in sidebar. Topbar now shows "Kanban" + freshness. |
|
||||
| 2026-04-07 | Diff size badges use `<abbr>` with tooltip | Minor: S/M/L diff badges were opaque. `<abbr title="Small (<100 lines)">` gives meaning on hover without adding visual noise. |
|
||||
|
|
|
|||
|
|
@ -11,6 +11,42 @@
|
|||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./activity-log": {
|
||||
"types": "./dist/activity-log.d.ts",
|
||||
"import": "./dist/activity-log.js"
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/config.d.ts",
|
||||
"import": "./dist/config.js"
|
||||
},
|
||||
"./decomposer": {
|
||||
"types": "./dist/decomposer.d.ts",
|
||||
"import": "./dist/decomposer.js"
|
||||
},
|
||||
"./lifecycle-manager": {
|
||||
"types": "./dist/lifecycle-manager.d.ts",
|
||||
"import": "./dist/lifecycle-manager.js"
|
||||
},
|
||||
"./metadata": {
|
||||
"types": "./dist/metadata.d.ts",
|
||||
"import": "./dist/metadata.js"
|
||||
},
|
||||
"./observability": {
|
||||
"types": "./dist/observability.d.ts",
|
||||
"import": "./dist/observability.js"
|
||||
},
|
||||
"./orchestrator-prompt": {
|
||||
"types": "./dist/orchestrator-prompt.d.ts",
|
||||
"import": "./dist/orchestrator-prompt.js"
|
||||
},
|
||||
"./paths": {
|
||||
"types": "./dist/paths.d.ts",
|
||||
"import": "./dist/paths.js"
|
||||
},
|
||||
"./plugin-registry": {
|
||||
"types": "./dist/plugin-registry.d.ts",
|
||||
"import": "./dist/plugin-registry.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"import": "./dist/types.js"
|
||||
|
|
@ -22,6 +58,10 @@
|
|||
"./scm-webhook-utils": {
|
||||
"types": "./dist/scm-webhook-utils.d.ts",
|
||||
"import": "./dist/scm-webhook-utils.js"
|
||||
},
|
||||
"./session-manager": {
|
||||
"types": "./dist/session-manager.d.ts",
|
||||
"import": "./dist/session-manager.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
|
|
@ -51,16 +51,32 @@ function setupCheck(
|
|||
configOverride?: OrchestratorConfig;
|
||||
},
|
||||
) {
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(opts.session);
|
||||
|
||||
writeMetadata(env.sessionsDir, sessionId, {
|
||||
const persistedMetadata = {
|
||||
worktree: "/tmp",
|
||||
branch: opts.session.branch ?? "main",
|
||||
status: opts.session.status,
|
||||
project: "my-app",
|
||||
runtimeHandle: opts.session.runtimeHandle
|
||||
? JSON.stringify(opts.session.runtimeHandle)
|
||||
: undefined,
|
||||
...opts.metaOverrides,
|
||||
};
|
||||
const persistedStringMetadata = Object.fromEntries(
|
||||
Object.entries(persistedMetadata).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue({
|
||||
...opts.session,
|
||||
metadata: {
|
||||
...opts.session.metadata,
|
||||
...persistedStringMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
writeMetadata(env.sessionsDir, sessionId, persistedMetadata);
|
||||
|
||||
return createLifecycleManager({
|
||||
config: opts.configOverride ?? config,
|
||||
registry: opts.registry ?? mockRegistry,
|
||||
|
|
@ -98,6 +114,52 @@ describe("check (single session)", () => {
|
|||
expect(meta!["status"]).toBe("working");
|
||||
});
|
||||
|
||||
it("does not kill a spawning session when its runtime handle has not been persisted yet", async () => {
|
||||
vi.mocked(plugins.runtime.isAlive).mockResolvedValue(false);
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({
|
||||
status: "spawning",
|
||||
runtimeHandle: { id: "app-1", runtimeName: "mock", data: {} },
|
||||
metadata: {},
|
||||
}),
|
||||
metaOverrides: {
|
||||
runtimeHandle: undefined,
|
||||
tmuxName: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("working");
|
||||
expect(plugins.runtime.isAlive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still probes a working session when it relies on a synthesized runtime handle", async () => {
|
||||
vi.mocked(plugins.runtime.isAlive).mockResolvedValue(false);
|
||||
|
||||
const lm = setupCheck("app-1", {
|
||||
session: makeSession({
|
||||
status: "working",
|
||||
runtimeHandle: { id: "app-1", runtimeName: "mock", data: {} },
|
||||
metadata: {},
|
||||
}),
|
||||
metaOverrides: {
|
||||
runtimeHandle: undefined,
|
||||
tmuxName: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(plugins.runtime.isAlive).toHaveBeenCalledWith({
|
||||
id: "app-1",
|
||||
runtimeName: "mock",
|
||||
data: {},
|
||||
});
|
||||
expect(lm.getStates().get("app-1")).toBe("killed");
|
||||
});
|
||||
|
||||
it("uses worker-specific agent fallback when metadata does not persist an agent", async () => {
|
||||
const codexAgent: Agent = {
|
||||
...plugins.agent,
|
||||
|
|
|
|||
|
|
@ -371,9 +371,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
|
||||
// Track activity state across steps so stuck detection can run after PR checks
|
||||
let detectedIdleTimestamp: Date | null = null;
|
||||
const hasPersistedRuntimeIdentity =
|
||||
typeof session.metadata["runtimeHandle"] === "string" ||
|
||||
typeof session.metadata["tmuxName"] === "string";
|
||||
const canProbeRuntimeIdentity =
|
||||
hasPersistedRuntimeIdentity || session.status !== SESSION_STATUS.SPAWNING;
|
||||
|
||||
// 1. Check if runtime is alive
|
||||
if (session.runtimeHandle) {
|
||||
if (session.runtimeHandle && canProbeRuntimeIdentity) {
|
||||
const runtime = registry.get<Runtime>("runtime", project.runtime ?? config.defaults.runtime);
|
||||
if (runtime) {
|
||||
const alive = await runtime.isAlive(session.runtimeHandle).catch(() => true);
|
||||
|
|
@ -381,12 +386,17 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
}
|
||||
}
|
||||
|
||||
// 2. Check agent activity — prefer JSONL-based detection (runtime-agnostic)
|
||||
if (agent && session.runtimeHandle) {
|
||||
// 2. Check agent activity.
|
||||
// JSONL-based activity detection is runtime-agnostic, but terminal probing
|
||||
// and process checks should only run once a real runtime identity has been
|
||||
// persisted. During spawn, sessionManager may fabricate a temporary handle
|
||||
// to keep the object shape stable; treating that as a real tmux target can
|
||||
// falsely mark a just-reserved session as killed before launch completes.
|
||||
if (agent && (session.runtimeHandle || session.workspacePath)) {
|
||||
try {
|
||||
// If the agent implements recordActivity, capture terminal output and record
|
||||
// BEFORE calling getActivityState so the JSONL has fresh data to read.
|
||||
if (agent.recordActivity && session.workspacePath) {
|
||||
if (agent.recordActivity && session.workspacePath && session.runtimeHandle && canProbeRuntimeIdentity) {
|
||||
try {
|
||||
const runtime = registry.get<Runtime>(
|
||||
"runtime",
|
||||
|
|
@ -420,17 +430,19 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
// proceed to PR checks below
|
||||
} else {
|
||||
// getActivityState returned null — fall back to terminal output parsing
|
||||
const runtime = registry.get<Runtime>(
|
||||
"runtime",
|
||||
project.runtime ?? config.defaults.runtime,
|
||||
);
|
||||
const terminalOutput = runtime ? await runtime.getOutput(session.runtimeHandle, 10) : "";
|
||||
if (terminalOutput) {
|
||||
const activity = agent.detectActivity(terminalOutput);
|
||||
if (activity === "waiting_input") return "needs_input";
|
||||
if (session.runtimeHandle && canProbeRuntimeIdentity) {
|
||||
const runtime = registry.get<Runtime>(
|
||||
"runtime",
|
||||
project.runtime ?? config.defaults.runtime,
|
||||
);
|
||||
const terminalOutput = runtime ? await runtime.getOutput(session.runtimeHandle, 10) : "";
|
||||
if (terminalOutput) {
|
||||
const activity = agent.detectActivity(terminalOutput);
|
||||
if (activity === "waiting_input") return "needs_input";
|
||||
|
||||
const processAlive = await agent.isProcessRunning(session.runtimeHandle);
|
||||
if (!processAlive) return "killed";
|
||||
const processAlive = await agent.isProcessRunning(session.runtimeHandle);
|
||||
if (!processAlive) return "killed";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ describe("SessionCard", () => {
|
|||
});
|
||||
const session = makeSession({ status: "ci_failed", activity: "idle", pr });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("1 CI check failing")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "1 check failing" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows CI status unknown when ciStatus is failing but no failed checks", () => {
|
||||
|
|
@ -393,7 +393,7 @@ describe("SessionCard", () => {
|
|||
});
|
||||
const session = makeSession({ activity: "idle", pr });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("ask to fix")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ask to fix" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows action buttons even when agent is active", () => {
|
||||
|
|
@ -412,13 +412,13 @@ describe("SessionCard", () => {
|
|||
});
|
||||
const session = makeSession({ activity: "active", pr });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getByText("ask to fix")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ask to fix" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows issue details in the compact card footer", () => {
|
||||
const session = makeSession({ id: "test-1", issueId: "INT-100", pr: null });
|
||||
render(<SessionCard session={session} />);
|
||||
expect(screen.getAllByText("INT-100")).toHaveLength(2);
|
||||
expect(screen.getAllByText("INT-100")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows icon-only terminate button in the footer", () => {
|
||||
|
|
@ -548,7 +548,7 @@ describe("SessionCard", () => {
|
|||
|
||||
render(<SessionCard session={session} onSend={onSend} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "ask to fix" });
|
||||
const actionButton = screen.getByRole("button", { name: "Ask to fix" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -558,7 +558,7 @@ describe("SessionCard", () => {
|
|||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "failed" })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "sent!" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Sent!" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -575,7 +575,26 @@ describe("AttentionZone", () => {
|
|||
|
||||
it("renders empty state when sessions array is empty", () => {
|
||||
render(<AttentionZone level="respond" sessions={[]} />);
|
||||
expect(screen.getByText("No sessions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Respond")).toBeInTheDocument();
|
||||
expect(screen.getByText("0")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No agents need your input.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders zone-specific empty messages for all attention zones", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["review", "Review"],
|
||||
["pending", "Pending"],
|
||||
["working", "Working"],
|
||||
["done", "Done"],
|
||||
];
|
||||
for (const [level, expectedLabel] of cases) {
|
||||
const { unmount } = render(
|
||||
<AttentionZone level={level as "review" | "pending" | "working" | "done"} sessions={[]} />,
|
||||
);
|
||||
expect(screen.getByText(expectedLabel)).toBeInTheDocument();
|
||||
expect(screen.getByText("0")).toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows session cards when not collapsed", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import * as matchers from "@testing-library/jest-dom/matchers";
|
||||
import { afterEach, expect } from "vitest";
|
||||
|
||||
expect.extend(matchers);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// jsdom does not implement window.matchMedia. Provide a minimal stub so
|
||||
// components that call useMediaQuery (e.g. Dashboard) work in unit tests.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/project-name", () => ({
|
||||
getProjectName: () => "Agent Orchestrator",
|
||||
}));
|
||||
|
||||
vi.mock("next/font/google", () => ({
|
||||
Geist: () => ({ variable: "--font-geist-sans" }),
|
||||
JetBrains_Mono: () => ({ variable: "--font-jetbrains-mono" }),
|
||||
}));
|
||||
|
||||
describe("app layout metadata", () => {
|
||||
it("exports the themed mobile viewport colors", async () => {
|
||||
const { viewport } = await import("./layout");
|
||||
|
||||
expect(viewport.themeColor).toEqual([
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f5f3f0" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#121110" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds metadata with the project-aware title and apple web app settings", async () => {
|
||||
const { generateMetadata } = await import("./layout");
|
||||
|
||||
await expect(generateMetadata()).resolves.toMatchObject({
|
||||
title: {
|
||||
template: "%s | Agent Orchestrator",
|
||||
default: "ao | Agent Orchestrator",
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: "ao | Agent Orchestrator",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -23,8 +23,8 @@ export const viewport: Viewport = {
|
|||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0d12" },
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f5f3f0" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#121110" },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/project-name", () => ({
|
||||
getProjectName: () => "Agent Orchestrator",
|
||||
}));
|
||||
|
||||
describe("app manifest", () => {
|
||||
it("builds the PWA manifest with project-aware naming and icons", async () => {
|
||||
const { default: manifest } = await import("./manifest");
|
||||
|
||||
expect(manifest()).toMatchObject({
|
||||
name: "ao | Agent Orchestrator",
|
||||
short_name: "ao",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#121110",
|
||||
theme_color: "#121110",
|
||||
icons: [
|
||||
{ src: "/apple-icon", sizes: "180x180", type: "image/png" },
|
||||
{ src: "/icon-192", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: "/icon-512", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -9,8 +9,8 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||
description: "Dashboard for managing parallel AI coding agents",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#0a0d12",
|
||||
theme_color: "#0a0d12",
|
||||
background_color: "#121110",
|
||||
theme_color: "#121110",
|
||||
icons: [
|
||||
{ src: "/apple-icon", sizes: "180x180", type: "image/png" },
|
||||
{ src: "/icon-192", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ export default function SessionPage() {
|
|||
const [session, setSession] = useState<DashboardSession | null>(null);
|
||||
const [zoneCounts, setZoneCounts] = useState<ZoneCounts | null>(null);
|
||||
const [projectOrchestratorId, setProjectOrchestratorId] = useState<string | null | undefined>(undefined);
|
||||
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
||||
const [sidebarSessions, setSidebarSessions] = useState<DashboardSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [routeError, setRouteError] = useState<Error | null>(null);
|
||||
const [sessionMissing, setSessionMissing] = useState(false);
|
||||
|
|
@ -85,6 +87,7 @@ export default function SessionPage() {
|
|||
.then((res) => res.ok ? res.json() : null)
|
||||
.then((data: { projects?: ProjectInfo[] } | null) => {
|
||||
if (data?.projects) {
|
||||
setProjects(data.projects);
|
||||
setPrefixByProject(
|
||||
new Map(data.projects.map((p) => [p.id, p.sessionPrefix ?? p.id])),
|
||||
);
|
||||
|
|
@ -185,6 +188,17 @@ export default function SessionPage() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const fetchSidebarSessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/sessions");
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { sessions?: DashboardSession[] } | null;
|
||||
setSidebarSessions(body?.sessions ?? []);
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionIsOrchestrator) {
|
||||
setZoneCounts(null);
|
||||
|
|
@ -194,19 +208,21 @@ export default function SessionPage() {
|
|||
// Initial fetch — session first, zone counts after (avoids blocking on slow /api/sessions)
|
||||
useEffect(() => {
|
||||
fetchSession();
|
||||
fetchSidebarSessions();
|
||||
// Delay zone counts so the heavy /api/sessions call doesn't contend with session load
|
||||
const t = setTimeout(fetchProjectSessions, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [fetchSession, fetchProjectSessions]);
|
||||
}, [fetchSession, fetchProjectSessions, fetchSidebarSessions]);
|
||||
|
||||
// Poll every 5s
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetchSession();
|
||||
fetchProjectSessions();
|
||||
fetchSidebarSessions();
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchSession, fetchProjectSessions]);
|
||||
}, [fetchSession, fetchProjectSessions, fetchSidebarSessions]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -235,6 +251,8 @@ export default function SessionPage() {
|
|||
isOrchestrator={sessionIsOrchestrator}
|
||||
orchestratorZones={zoneCounts ?? undefined}
|
||||
projectOrchestratorId={projectOrchestratorId}
|
||||
projects={projects}
|
||||
sidebarSessions={sidebarSessions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,13 @@
|
|||
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const activityConfig: Record<
|
||||
string,
|
||||
{ label: string; dot: string; bg: string; text: string }
|
||||
> = {
|
||||
active: { label: "active", dot: "var(--color-status-working)", bg: "rgba(34,197,94,0.1)", text: "var(--color-status-working)" },
|
||||
ready: { label: "ready", dot: "var(--color-status-ready)", bg: "rgba(91,126,248,0.1)", text: "var(--color-status-ready)" },
|
||||
idle: { label: "idle", dot: "var(--color-status-idle)", bg: "rgba(72,79,88,0.25)", text: "var(--color-text-secondary)" },
|
||||
waiting_input: { label: "waiting", dot: "var(--color-status-attention)", bg: "rgba(210,153,34,0.12)", text: "var(--color-status-attention)" },
|
||||
blocked: { label: "blocked", dot: "var(--color-status-error)", bg: "rgba(248,81,73,0.1)", text: "var(--color-status-error)" },
|
||||
exited: { label: "exited", dot: "var(--color-status-done)", bg: "rgba(48,54,61,0.5)", text: "var(--color-text-muted)" },
|
||||
};
|
||||
|
||||
const fallbackConfig = {
|
||||
dot: "var(--color-text-tertiary)",
|
||||
bg: "rgba(74,74,74,0.2)",
|
||||
text: "var(--color-text-muted)",
|
||||
const activityLabels: Record<string, string> = {
|
||||
active: "active",
|
||||
ready: "ready",
|
||||
idle: "idle",
|
||||
waiting_input: "waiting",
|
||||
blocked: "blocked",
|
||||
exited: "exited",
|
||||
};
|
||||
|
||||
interface ActivityDotProps {
|
||||
|
|
@ -28,32 +19,31 @@ interface ActivityDotProps {
|
|||
}
|
||||
|
||||
export function ActivityDot({ activity, dotOnly = false, size = 6 }: ActivityDotProps) {
|
||||
const c = (activity !== null && activityConfig[activity]) || {
|
||||
label: activity ?? "unknown",
|
||||
...fallbackConfig,
|
||||
};
|
||||
const label = (activity !== null && activityLabels[activity]) || activity || "unknown";
|
||||
const dataActivity = activity ?? undefined;
|
||||
const isPulsing = activity === "active";
|
||||
|
||||
if (dotOnly) {
|
||||
return (
|
||||
<div
|
||||
className={cn("shrink-0 rounded-full", isPulsing && "dot-pulse")}
|
||||
style={{ width: size, height: size, background: c.dot }}
|
||||
className={cn("activity-dot shrink-0 rounded-full", isPulsing && "dot-pulse")}
|
||||
style={{ width: size, height: size }}
|
||||
data-activity={dataActivity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5"
|
||||
style={{ background: c.bg }}
|
||||
className="activity-pill inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5"
|
||||
data-activity={dataActivity}
|
||||
>
|
||||
<span
|
||||
className={cn("h-1.5 w-1.5 shrink-0 rounded-full", isPulsing && "dot-pulse")}
|
||||
style={{ background: c.dot }}
|
||||
className={cn("activity-dot h-1.5 w-1.5 shrink-0 rounded-full", isPulsing && "dot-pulse")}
|
||||
data-activity={dataActivity}
|
||||
/>
|
||||
<span className="text-[10px] font-medium" style={{ color: c.text }}>
|
||||
{c.label}
|
||||
<span className="activity-pill__text text-[10px] font-medium" data-activity={dataActivity}>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -32,39 +32,32 @@ const zoneConfig: Record<
|
|||
AttentionLevel,
|
||||
{
|
||||
label: string;
|
||||
color: string;
|
||||
caption: string;
|
||||
emptyMessage: string;
|
||||
}
|
||||
> = {
|
||||
merge: {
|
||||
label: "Ready",
|
||||
color: "var(--color-status-ready)",
|
||||
caption: "Cleared to land",
|
||||
emptyMessage: "Nothing cleared to land yet.",
|
||||
},
|
||||
respond: {
|
||||
label: "Respond",
|
||||
color: "var(--color-status-error)",
|
||||
caption: "Human judgment needed",
|
||||
emptyMessage: "No agents need your input.",
|
||||
},
|
||||
review: {
|
||||
label: "Review",
|
||||
color: "var(--color-accent-orange)",
|
||||
caption: "Code waiting on eyes",
|
||||
emptyMessage: "No code waiting for review.",
|
||||
},
|
||||
pending: {
|
||||
label: "Pending",
|
||||
color: "var(--color-status-attention)",
|
||||
caption: "Waiting on external state",
|
||||
emptyMessage: "Nothing blocked.",
|
||||
},
|
||||
working: {
|
||||
label: "Working",
|
||||
color: "var(--color-status-working)",
|
||||
caption: "Agents are actively moving",
|
||||
emptyMessage: "No agents running.",
|
||||
},
|
||||
done: {
|
||||
label: "Done",
|
||||
color: "var(--color-text-tertiary)",
|
||||
caption: "Completed or exited",
|
||||
emptyMessage: "No completed sessions.",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -118,14 +111,15 @@ function AttentionZoneView({
|
|||
className="accordion-header"
|
||||
onClick={() => onToggle(level)}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={`accordion-body-${level}`}
|
||||
>
|
||||
<span className="accordion-header__dot" style={{ background: config.color }} />
|
||||
<span className="accordion-header__dot" data-level={level} />
|
||||
<span className="accordion-header__label">{config.label}</span>
|
||||
<span className="accordion-header__count">{sessions.length}</span>
|
||||
<span className="accordion-header__chevron" aria-hidden="true">▶</span>
|
||||
</button>
|
||||
|
||||
<div className="accordion-body">
|
||||
<div id={`accordion-body-${level}`} className="accordion-body">
|
||||
{sessions.length > 0 ? (
|
||||
<div className={compactMobile ? "mobile-session-list" : "flex flex-col gap-2 p-3"}>
|
||||
{visibleSessions.map((session) =>
|
||||
|
|
@ -159,7 +153,7 @@ function AttentionZoneView({
|
|||
</div>
|
||||
) : compactMobile ? (
|
||||
<div className="mobile-session-list">
|
||||
<div className="mobile-session-list__empty">No sessions</div>
|
||||
<div className="mobile-session-list__empty">{config.emptyMessage}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -171,16 +165,15 @@ function AttentionZoneView({
|
|||
<div className="kanban-column" data-level={level}>
|
||||
<div className="kanban-column__header">
|
||||
<div className="kanban-column__title-row">
|
||||
<div className="kanban-column__dot" style={{ background: config.color }} />
|
||||
<div className="kanban-column__dot" data-level={level} />
|
||||
<span className="kanban-column__title">{config.label}</span>
|
||||
<span className="kanban-column__count">{sessions.length}</span>
|
||||
</div>
|
||||
<p className="kanban-column__caption">{config.caption}</p>
|
||||
</div>
|
||||
|
||||
<div className="kanban-column-body">
|
||||
{sessions.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="kanban-column__stack">
|
||||
{sessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
|
|
@ -192,11 +185,7 @@ function AttentionZoneView({
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="kanban-column__empty">
|
||||
<span className="kanban-column__empty-label">No sessions</span>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -247,7 +236,7 @@ function MobileSessionRow({
|
|||
<div className="mobile-session-row__line">
|
||||
<span
|
||||
className="mobile-session-row__dot"
|
||||
style={{ background: zoneConfig[level].color }}
|
||||
data-level={level}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="mobile-session-row__title">{getSessionTitle(session)}</span>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { getAttentionLevel, type DashboardSession } from "@/lib/types";
|
||||
import { getAttentionLevel, isPRRateLimited, isPRUnenriched, type DashboardSession } from "@/lib/types";
|
||||
import { getSessionTitle } from "@/lib/format";
|
||||
|
||||
function getRelativeTime(dateStr: string): string {
|
||||
|
|
@ -125,6 +125,9 @@ export function BottomSheet({
|
|||
session.summary && !session.summaryIsFallback ? session.summary : null;
|
||||
const hasLiveTerminateAction =
|
||||
attention !== "done" && attention !== "merge" && session.status !== "terminated";
|
||||
const pr = session.pr;
|
||||
const showLivePrData = Boolean(pr && !isPRRateLimited(pr) && !isPRUnenriched(pr));
|
||||
const showTerminalStatePills = attention === "done" || session.status === "terminated" || session.activity === "exited";
|
||||
const tags = [
|
||||
{ label: formatTagLabel(attention), tone: "accent" as const },
|
||||
{ label: formatTagLabel(session.status), tone: "neutral" as const },
|
||||
|
|
@ -156,33 +159,104 @@ export function BottomSheet({
|
|||
{/* Drag handle */}
|
||||
<div className="bottom-sheet__handle" aria-hidden="true" />
|
||||
|
||||
<div className="bottom-sheet__header">
|
||||
<h2 id="bottom-sheet-title" className="bottom-sheet__title">
|
||||
{mode === "confirm-kill" ? "Terminate session?" : title}
|
||||
</h2>
|
||||
<p className="bottom-sheet__subtitle">
|
||||
{mode === "confirm-kill"
|
||||
? "This action cannot be undone."
|
||||
: `${attention} · started ${getRelativeTime(session.createdAt)}`}
|
||||
</p>
|
||||
</div>
|
||||
{mode === "confirm-kill" ? (
|
||||
<>
|
||||
<div className="bottom-sheet__header">
|
||||
<h2 id="bottom-sheet-title" className="bottom-sheet__title">
|
||||
Terminate session?
|
||||
</h2>
|
||||
<p className="bottom-sheet__subtitle">This action cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<div className="bottom-sheet__session-info">
|
||||
{mode === "confirm-kill" ? (
|
||||
<div className="bottom-sheet__session-name">{title}</div>
|
||||
) : null}
|
||||
<div className="bottom-sheet__session-meta">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={`${tag.tone}-${tag.label}`}
|
||||
className={`bottom-sheet__tag bottom-sheet__tag--${tag.tone}`}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{summary ? <p className="bottom-sheet__summary">{summary}</p> : null}
|
||||
</div>
|
||||
<div className="bottom-sheet__session-info">
|
||||
<div className="bottom-sheet__session-name">{title}</div>
|
||||
<div className="bottom-sheet__session-meta">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={`${tag.tone}-${tag.label}`}
|
||||
className={`bottom-sheet__tag bottom-sheet__tag--${tag.tone}`}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{summary ? <p className="bottom-sheet__summary">{summary}</p> : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bottom-sheet__preview-card">
|
||||
<div className="bottom-sheet__preview-strip" data-level={attention} />
|
||||
<div className="bottom-sheet__preview-content">
|
||||
<div className="bottom-sheet__preview-header">
|
||||
<span className="bottom-sheet__preview-id">{session.id}</span>
|
||||
<span className="bottom-sheet__preview-time">{getRelativeTime(session.lastActivityAt)}</span>
|
||||
</div>
|
||||
<h2 id="bottom-sheet-title" className="bottom-sheet__title">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="bottom-sheet__subtitle">
|
||||
{formatTagLabel(attention)} · started {getRelativeTime(session.createdAt)}
|
||||
</p>
|
||||
|
||||
<div className="bottom-sheet__preview-meta">
|
||||
{session.branch ? (
|
||||
<span className="bottom-sheet__preview-branch">{session.branch}</span>
|
||||
) : null}
|
||||
{pr ? <span className="bottom-sheet__preview-pr">#{pr.number}</span> : null}
|
||||
{showLivePrData && pr ? (
|
||||
<span className="bottom-sheet__preview-diff">
|
||||
<span className="bottom-sheet__preview-diff-add">+{pr.additions}</span>
|
||||
{" "}
|
||||
<span className="bottom-sheet__preview-diff-del">-{pr.deletions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showLivePrData && pr ? (
|
||||
<div className="bottom-sheet__preview-pills">
|
||||
<span className="bottom-sheet__tag bottom-sheet__tag--neutral">
|
||||
{pr.ciStatus === "passing"
|
||||
? "CI passing"
|
||||
: pr.ciStatus === "failing"
|
||||
? "CI failed"
|
||||
: "CI pending"}
|
||||
</span>
|
||||
<span className="bottom-sheet__tag bottom-sheet__tag--accent">
|
||||
{pr.reviewDecision === "approved"
|
||||
? "approved"
|
||||
: pr.reviewDecision === "changes_requested"
|
||||
? "changes requested"
|
||||
: "needs review"}
|
||||
</span>
|
||||
{showTerminalStatePills ? (
|
||||
<span className="bottom-sheet__tag bottom-sheet__tag--accent">
|
||||
{formatTagLabel(session.status)}
|
||||
</span>
|
||||
) : null}
|
||||
{showTerminalStatePills && session.activity ? (
|
||||
<span className="bottom-sheet__tag bottom-sheet__tag--neutral">
|
||||
{formatTagLabel(session.activity)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bottom-sheet__preview-pills">
|
||||
<span className="bottom-sheet__tag bottom-sheet__tag--accent">
|
||||
{formatTagLabel(session.status)}
|
||||
</span>
|
||||
{session.activity ? (
|
||||
<span className="bottom-sheet__tag bottom-sheet__tag--neutral">
|
||||
{formatTagLabel(session.activity)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{summary ? <p className="bottom-sheet__summary">{summary}</p> : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="bottom-sheet__actions">
|
||||
{mode === "confirm-kill" ? (
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -18,11 +18,13 @@ interface DirectTerminalProps {
|
|||
startFullscreen?: boolean;
|
||||
/** Visual variant. Orchestrator keeps the same design-system blue accent as the rest of the app. */
|
||||
variant?: "agent" | "orchestrator";
|
||||
appearance?: "theme" | "dark";
|
||||
/** CSS height for the terminal container in normal (non-fullscreen) mode.
|
||||
* Defaults to "max(440px, calc(100vh - 440px))". */
|
||||
height?: string;
|
||||
isOpenCodeSession?: boolean;
|
||||
reloadCommand?: string;
|
||||
chromeless?: boolean;
|
||||
}
|
||||
|
||||
type TerminalVariant = "agent" | "orchestrator";
|
||||
|
|
@ -106,9 +108,11 @@ export function DirectTerminal({
|
|||
sessionId,
|
||||
startFullscreen = false,
|
||||
variant = "agent",
|
||||
appearance = "theme",
|
||||
height = "max(440px, calc(100dvh - 440px))",
|
||||
isOpenCodeSession = false,
|
||||
reloadCommand,
|
||||
chromeless = false,
|
||||
}: DirectTerminalProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
|
@ -198,7 +202,7 @@ export function DirectTerminal({
|
|||
.then(([Terminal, FitAddon, WebLinksAddon]) => {
|
||||
if (!mounted || !terminalRef.current) return;
|
||||
|
||||
const isDark = resolvedTheme !== "light";
|
||||
const isDark = appearance === "dark" || resolvedTheme !== "light";
|
||||
const activeTheme = isDark ? terminalThemes.dark : terminalThemes.light;
|
||||
|
||||
// Initialize xterm.js Terminal
|
||||
|
|
@ -384,7 +388,18 @@ export function DirectTerminal({
|
|||
mounted = false;
|
||||
cleanup?.();
|
||||
};
|
||||
}, [sessionId, variant, subscribeTerminal, writeTerminal, resizeTerminalMux, openTerminal, closeTerminal]);
|
||||
}, [
|
||||
appearance,
|
||||
sessionId,
|
||||
variant,
|
||||
resolvedTheme,
|
||||
terminalThemes,
|
||||
subscribeTerminal,
|
||||
writeTerminal,
|
||||
resizeTerminalMux,
|
||||
openTerminal,
|
||||
closeTerminal,
|
||||
]);
|
||||
|
||||
// Re-send terminal dimensions on every reconnect so the server-side PTY
|
||||
// matches the client's xterm.js size (new PTYs spawn at 80×24 default).
|
||||
|
|
@ -401,10 +416,10 @@ export function DirectTerminal({
|
|||
useEffect(() => {
|
||||
const terminal = terminalInstance.current;
|
||||
if (!terminal) return;
|
||||
const isDark = resolvedTheme !== "light";
|
||||
const isDark = appearance === "dark" || resolvedTheme !== "light";
|
||||
terminal.options.theme = isDark ? terminalThemes.dark : terminalThemes.light;
|
||||
terminal.options.minimumContrastRatio = isDark ? 1 : 7;
|
||||
}, [resolvedTheme, terminalThemes]);
|
||||
}, [appearance, resolvedTheme, terminalThemes]);
|
||||
|
||||
// Re-fit terminal when fullscreen changes
|
||||
useEffect(() => {
|
||||
|
|
@ -514,118 +529,167 @@ export function DirectTerminal({
|
|||
: displayStatus === "error" || displayStatus === "disconnected"
|
||||
? "text-[var(--color-status-error)]"
|
||||
: "text-[var(--color-text-tertiary)]";
|
||||
const isDarkChrome = appearance === "dark" || resolvedTheme !== "light";
|
||||
const fullscreenButton = (
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)]",
|
||||
!isOpenCodeSession && !chromeless && "ml-auto",
|
||||
)}
|
||||
aria-label={fullscreen ? "exit fullscreen" : "fullscreen"}
|
||||
>
|
||||
{fullscreen ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3" />
|
||||
</svg>
|
||||
exit fullscreen
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" />
|
||||
</svg>
|
||||
fullscreen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden border border-[var(--color-border-default)]",
|
||||
resolvedTheme === "light" ? "bg-[#fafafa]" : "bg-[#0a0a0f]",
|
||||
"relative overflow-hidden border border-[var(--color-border-default)]",
|
||||
isDarkChrome ? "bg-[#0a0a0f]" : "bg-[#fafafa]",
|
||||
fullscreen && "fixed inset-0 z-50 rounded-none border-0",
|
||||
chromeless && "border-0",
|
||||
)}
|
||||
>
|
||||
{/* Terminal chrome bar */}
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)] px-3 py-2">
|
||||
<div className={cn("h-2 w-2 shrink-0 rounded-full", statusDotClass)} />
|
||||
<span className="font-[var(--font-mono)] text-[11px]" style={{ color: accentColor }}>
|
||||
{sessionId}
|
||||
</span>
|
||||
<span
|
||||
className={cn("text-[10px] font-medium uppercase tracking-[0.06em]", statusTextColor)}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
{/* XDA clipboard badge */}
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.06em]"
|
||||
style={{
|
||||
color: accentColor,
|
||||
background: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
|
||||
}}
|
||||
>
|
||||
XDA
|
||||
</span>
|
||||
{isOpenCodeSession ? (
|
||||
<button
|
||||
onClick={handleReload}
|
||||
disabled={reloading || muxStatus !== "connected"}
|
||||
title="Restart OpenCode session (/exit then resume mapped session)"
|
||||
aria-label="Restart OpenCode session"
|
||||
className="ml-auto flex items-center gap-1 px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{reloading ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-3 w-3 animate-spin"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 3a9 9 0 109 9" />
|
||||
</svg>
|
||||
restarting
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M21 12a9 9 0 11-2.64-6.36" />
|
||||
<path d="M21 3v6h-6" />
|
||||
</svg>
|
||||
restart
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{reloadError ? (
|
||||
<span
|
||||
className="max-w-[40ch] truncate text-[10px] font-medium text-[var(--color-status-error)]"
|
||||
title={reloadError}
|
||||
>
|
||||
{reloadError}
|
||||
{!chromeless ? (
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)] px-3 py-2">
|
||||
<div className={cn("h-2 w-2 shrink-0 rounded-full", statusDotClass)} />
|
||||
<span className="font-[var(--font-mono)] text-[11px]" style={{ color: accentColor }}>
|
||||
{sessionId}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)]",
|
||||
!isOpenCodeSession && "ml-auto",
|
||||
)}
|
||||
>
|
||||
{fullscreen ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3" />
|
||||
</svg>
|
||||
exit fullscreen
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" />
|
||||
</svg>
|
||||
fullscreen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
className={cn("text-[10px] font-medium uppercase tracking-[0.06em]", statusTextColor)}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.06em]"
|
||||
style={{
|
||||
color: accentColor,
|
||||
background: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
|
||||
}}
|
||||
>
|
||||
XDA
|
||||
</span>
|
||||
{isOpenCodeSession ? (
|
||||
<button
|
||||
onClick={handleReload}
|
||||
disabled={reloading || muxStatus !== "connected"}
|
||||
title="Restart OpenCode session (/exit then resume mapped session)"
|
||||
aria-label="Restart OpenCode session"
|
||||
className="ml-auto flex items-center gap-1 px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{reloading ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-3 w-3 animate-spin"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 3a9 9 0 109 9" />
|
||||
</svg>
|
||||
restarting
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M21 12a9 9 0 11-2.64-6.36" />
|
||||
<path d="M21 3v6h-6" />
|
||||
</svg>
|
||||
restart
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{reloadError ? (
|
||||
<span
|
||||
className="max-w-[40ch] truncate text-[10px] font-medium text-[var(--color-status-error)]"
|
||||
title={reloadError}
|
||||
>
|
||||
{reloadError}
|
||||
</span>
|
||||
) : null}
|
||||
{fullscreenButton}
|
||||
</div>
|
||||
) : null}
|
||||
{chromeless ? (
|
||||
<div className="absolute right-3 top-3 z-10 flex items-center gap-1 rounded-[6px] border border-[var(--color-border-subtle)] bg-[color-mix(in_srgb,var(--color-bg-elevated)_92%,transparent)] px-1.5 py-1 shadow-[0_8px_24px_rgba(0,0,0,0.18)] backdrop-blur-sm">
|
||||
{isOpenCodeSession ? (
|
||||
<button
|
||||
onClick={handleReload}
|
||||
disabled={reloading || muxStatus !== "connected"}
|
||||
title="Restart OpenCode session (/exit then resume mapped session)"
|
||||
aria-label="Restart OpenCode session"
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{reloading ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-3 w-3 animate-spin"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 3a9 9 0 109 9" />
|
||||
</svg>
|
||||
restarting
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M21 12a9 9 0 11-2.64-6.36" />
|
||||
<path d="M21 3v6h-6" />
|
||||
</svg>
|
||||
restart
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{fullscreenButton}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Terminal area */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
|
|
@ -634,7 +698,7 @@ export function DirectTerminal({
|
|||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: fullscreen ? "calc(100dvh - 37px)" : height,
|
||||
height: fullscreen ? `calc(100dvh - ${chromeless ? "0px" : "37px"})` : height,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -72,9 +72,10 @@ export function PRStatus({ pr }: PRStatusProps) {
|
|||
|
||||
interface PRTableRowProps {
|
||||
pr: DashboardPR;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export function PRTableRow({ pr }: PRTableRowProps) {
|
||||
export function PRTableRow({ pr, muted = false }: PRTableRowProps) {
|
||||
const sizeLabel = getSizeLabel(pr.additions, pr.deletions);
|
||||
const rateLimited = isPRRateLimited(pr);
|
||||
const unenriched = isPRUnenriched(pr);
|
||||
|
|
@ -82,6 +83,10 @@ export function PRTableRow({ pr }: PRTableRowProps) {
|
|||
|
||||
const reviewLabel = hideData
|
||||
? "—"
|
||||
: pr.state === "merged"
|
||||
? "merged"
|
||||
: pr.state === "closed"
|
||||
? "closed"
|
||||
: pr.isDraft
|
||||
? "draft"
|
||||
: pr.reviewDecision === "approved"
|
||||
|
|
@ -103,7 +108,7 @@ export function PRTableRow({ pr }: PRTableRowProps) {
|
|||
const shimmer = <span className="inline-block h-3 w-12 animate-pulse rounded bg-[var(--color-bg-subtle)]" />;
|
||||
|
||||
return (
|
||||
<tr className="border-b border-[var(--color-border-muted)] hover:bg-[rgba(88,166,255,0.03)]">
|
||||
<tr className={`border-b border-[var(--color-border-subtle)] transition-colors hover:bg-[var(--color-bg-subtle)]${muted ? " opacity-60 hover:opacity-100" : ""}`}>
|
||||
<td className="px-3 py-2.5 text-sm">
|
||||
<a href={pr.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||
#{pr.number}
|
||||
|
|
@ -140,47 +145,91 @@ export function PRTableRow({ pr }: PRTableRowProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export function PRCard({ pr }: PRTableRowProps) {
|
||||
const sizeLabel = getSizeLabel(pr.additions, pr.deletions);
|
||||
function getCiDotColor(pr: DashboardPR): string {
|
||||
if (pr.ciStatus === "passing") return "var(--color-accent-green)";
|
||||
if (pr.ciStatus === "failing") return "var(--color-accent-red)";
|
||||
return "var(--color-status-attention)";
|
||||
}
|
||||
|
||||
function getCiTextColor(pr: DashboardPR): string {
|
||||
if (pr.ciStatus === "passing") return "var(--color-accent-green)";
|
||||
if (pr.ciStatus === "failing") return "var(--color-accent-red)";
|
||||
return "var(--color-text-secondary)";
|
||||
}
|
||||
|
||||
function getReviewColor(pr: DashboardPR): string {
|
||||
if (pr.reviewDecision === "approved") return "var(--color-accent-green)";
|
||||
if (pr.reviewDecision === "changes_requested") return "var(--color-accent-red)";
|
||||
return "var(--color-text-secondary)";
|
||||
}
|
||||
|
||||
export function PRCard({ pr, muted = false }: PRTableRowProps) {
|
||||
const rateLimited = isPRRateLimited(pr);
|
||||
const unenriched = isPRUnenriched(pr);
|
||||
const hideData = rateLimited || unenriched;
|
||||
|
||||
const reviewLabel = rateLimited || unenriched
|
||||
? "—"
|
||||
: pr.isDraft
|
||||
? "draft"
|
||||
: pr.reviewDecision === "approved"
|
||||
? "approved"
|
||||
: pr.reviewDecision === "changes_requested"
|
||||
? "changes"
|
||||
: "review";
|
||||
|
||||
const ciLabel = rateLimited || unenriched
|
||||
const ciLabel = hideData
|
||||
? "—"
|
||||
: pr.ciStatus === "passing"
|
||||
? "CI passing"
|
||||
? "passing"
|
||||
: pr.ciStatus === "failing"
|
||||
? "CI failing"
|
||||
: "CI pending";
|
||||
? "failed"
|
||||
: "pending";
|
||||
|
||||
const reviewLabel = hideData
|
||||
? "—"
|
||||
: pr.state === "merged"
|
||||
? "merged"
|
||||
: pr.state === "closed"
|
||||
? "closed"
|
||||
: pr.isDraft
|
||||
? "draft"
|
||||
: pr.reviewDecision === "approved"
|
||||
? "approved"
|
||||
: pr.reviewDecision === "changes_requested"
|
||||
? "changes"
|
||||
: "needs review";
|
||||
|
||||
const shimmer = <span className="inline-block h-3 w-10 animate-pulse rounded bg-[var(--color-bg-subtle)]" />;
|
||||
const diffLabel = hideData ? null : `+${pr.additions} -${pr.deletions}`;
|
||||
const lineTone =
|
||||
pr.state === "merged"
|
||||
? "mobile-pr-card__meta--merged"
|
||||
: pr.state === "closed"
|
||||
? "mobile-pr-card__meta--closed"
|
||||
: pr.reviewDecision === "changes_requested"
|
||||
? "mobile-pr-card__meta--changes"
|
||||
: pr.reviewDecision === "approved"
|
||||
? "mobile-pr-card__meta--approved"
|
||||
: "mobile-pr-card__meta--open";
|
||||
|
||||
return (
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mobile-pr-card"
|
||||
className={`mobile-pr-card${muted ? " mobile-pr-card--muted" : ""}`}
|
||||
>
|
||||
<div className="mobile-pr-card__line">
|
||||
<span className="mobile-pr-card__number">#{pr.number}</span>
|
||||
<span className="mobile-pr-card__title">{pr.title}</span>
|
||||
{!rateLimited && !unenriched ? <span className="mobile-pr-card__size">{sizeLabel}</span> : null}
|
||||
</div>
|
||||
<div className="mobile-pr-card__meta">
|
||||
<span>{unenriched ? shimmer : ciLabel}</span>
|
||||
<span>{unenriched ? shimmer : reviewLabel}</span>
|
||||
<span>{unenriched ? shimmer : `${pr.unresolvedThreads} threads`}</span>
|
||||
<div className={`mobile-pr-card__meta ${lineTone}`}>
|
||||
{unenriched ? shimmer : (
|
||||
<span className="mobile-pr-card__metric-value">
|
||||
<span
|
||||
className="mobile-pr-card__ci-dot"
|
||||
style={{ background: hideData ? "var(--color-text-tertiary)" : getCiDotColor(pr) }}
|
||||
/>
|
||||
<span style={{ color: hideData ? undefined : getCiTextColor(pr) }}>{ciLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="mobile-pr-card__review" style={{ color: hideData ? undefined : getReviewColor(pr) }}>
|
||||
{unenriched ? shimmer : reviewLabel}
|
||||
</span>
|
||||
<span className="mobile-pr-card__diff">
|
||||
{hideData ? shimmer : diffLabel}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { ProjectInfo } from "@/lib/project-name";
|
||||
import { getAttentionLevel, type DashboardSession, type AttentionLevel } from "@/lib/types";
|
||||
import { isOrchestratorSession } from "@aoagents/ao-core/types";
|
||||
import { getSessionTitle } from "@/lib/format";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
interface ProjectSidebarProps {
|
||||
projects: ProjectInfo[];
|
||||
|
|
@ -19,81 +20,33 @@ interface ProjectSidebarProps {
|
|||
onMobileClose?: () => void;
|
||||
}
|
||||
|
||||
type ProjectHealth = "red" | "yellow" | "green" | "gray";
|
||||
type SessionDotLevel = "respond" | "review" | "pending" | "working" | "merge" | "done";
|
||||
|
||||
function computeProjectHealth(
|
||||
sessions: DashboardSession[],
|
||||
prefixByProject: Map<string, string>,
|
||||
allPrefixes: string[],
|
||||
): ProjectHealth {
|
||||
const workers = sessions.filter(
|
||||
(s) => !isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes),
|
||||
function SessionDot({ level }: { level: SessionDotLevel }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sidebar-session-dot shrink-0 rounded-full",
|
||||
level === "working" && "sidebar-session-dot--glow",
|
||||
)}
|
||||
data-level={level}
|
||||
/>
|
||||
);
|
||||
if (workers.length === 0) return "gray";
|
||||
for (const s of workers) {
|
||||
if (getAttentionLevel(s) === "respond") return "red";
|
||||
}
|
||||
for (const s of workers) {
|
||||
const lvl = getAttentionLevel(s);
|
||||
if (lvl === "review" || lvl === "pending") return "yellow";
|
||||
}
|
||||
return "green";
|
||||
}
|
||||
|
||||
const healthDotColor: Record<ProjectHealth, string> = {
|
||||
red: "var(--color-status-error)",
|
||||
yellow: "var(--color-status-attention)",
|
||||
green: "var(--color-status-ready)",
|
||||
gray: "var(--color-text-tertiary)",
|
||||
};
|
||||
|
||||
const sessionDotColor: Record<AttentionLevel, string> = {
|
||||
merge: "var(--color-status-ready)",
|
||||
respond: "var(--color-status-error)",
|
||||
review: "var(--color-accent-orange)",
|
||||
pending: "var(--color-status-attention)",
|
||||
working: "var(--color-status-working)",
|
||||
done: "var(--color-text-tertiary)",
|
||||
};
|
||||
|
||||
const sessionToneLabel: Record<AttentionLevel, string> = {
|
||||
merge: "merge",
|
||||
respond: "reply",
|
||||
const LEVEL_LABELS: Record<AttentionLevel, string> = {
|
||||
working: "working",
|
||||
pending: "pending",
|
||||
review: "review",
|
||||
pending: "wait",
|
||||
working: "live",
|
||||
respond: "respond",
|
||||
merge: "merge",
|
||||
done: "done",
|
||||
};
|
||||
|
||||
function SessionDot({ level }: { level: AttentionLevel }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-[7px] w-[7px] shrink-0 rounded-full",
|
||||
level === "respond" && "animate-[activity-pulse_2s_ease-in-out_infinite]",
|
||||
)}
|
||||
style={{ background: sessionDotColor[level] }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthDot({ health }: { health: ProjectHealth }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
health === "red" && "animate-[activity-pulse_2s_ease-in-out_infinite]",
|
||||
)}
|
||||
style={{ background: healthDotColor[health] }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectSidebar(props: ProjectSidebarProps) {
|
||||
if (props.projects.length <= 1) {
|
||||
if (props.projects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ProjectSidebarInner {...props} />;
|
||||
}
|
||||
|
||||
|
|
@ -103,12 +56,11 @@ function ProjectSidebarInner({
|
|||
activeProjectId,
|
||||
activeSessionId,
|
||||
collapsed = false,
|
||||
onToggleCollapsed,
|
||||
onToggleCollapsed: _onToggleCollapsed,
|
||||
mobileOpen = false,
|
||||
onMobileClose,
|
||||
}: ProjectSidebarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
|
||||
() => new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []),
|
||||
|
|
@ -120,6 +72,32 @@ function ProjectSidebarInner({
|
|||
}
|
||||
}, [activeProjectId]);
|
||||
|
||||
const prefixByProject = useMemo(
|
||||
() => new Map(projects.map((p) => [p.id, p.sessionPrefix ?? p.id])),
|
||||
[projects],
|
||||
);
|
||||
|
||||
const allPrefixes = useMemo(
|
||||
() => projects.map((p) => p.sessionPrefix ?? p.id),
|
||||
[projects],
|
||||
);
|
||||
|
||||
const sessionsByProject = useMemo(() => {
|
||||
const map = new Map<string, DashboardSession[]>();
|
||||
for (const s of sessions) {
|
||||
if (isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes)) continue;
|
||||
const list = map.get(s.projectId) ?? [];
|
||||
list.push(s);
|
||||
map.set(s.projectId, list);
|
||||
}
|
||||
return map;
|
||||
}, [sessions, prefixByProject, allPrefixes]);
|
||||
|
||||
const navigate = (url: string) => {
|
||||
router.push(url);
|
||||
onMobileClose?.();
|
||||
};
|
||||
|
||||
const toggleExpand = (projectId: string) => {
|
||||
setExpandedProjects((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
|
@ -132,282 +110,143 @@ function ProjectSidebarInner({
|
|||
});
|
||||
};
|
||||
|
||||
const handleProjectHeaderClick = (projectId: string) => {
|
||||
toggleExpand(projectId);
|
||||
router.push(pathname + `?project=${encodeURIComponent(projectId)}`);
|
||||
};
|
||||
|
||||
const prefixByProject = useMemo(
|
||||
() => new Map(projects.map((p) => [p.id, p.sessionPrefix ?? p.id])),
|
||||
[projects],
|
||||
);
|
||||
|
||||
const allPrefixes = useMemo(
|
||||
() => projects.map((p) => p.sessionPrefix ?? p.id),
|
||||
[projects],
|
||||
);
|
||||
|
||||
const sessionsByProject = useMemo(() => {
|
||||
const map = new Map<string, { all: DashboardSession[]; workers: DashboardSession[] }>();
|
||||
let totalWorkers = 0;
|
||||
let needsInput = 0;
|
||||
let reviewLoad = 0;
|
||||
|
||||
for (const s of sessions) {
|
||||
let entry = map.get(s.projectId);
|
||||
if (!entry) {
|
||||
entry = { all: [], workers: [] };
|
||||
map.set(s.projectId, entry);
|
||||
}
|
||||
entry.all.push(s);
|
||||
if (!isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes)) {
|
||||
entry.workers.push(s);
|
||||
totalWorkers++;
|
||||
}
|
||||
const lvl = getAttentionLevel(s);
|
||||
if (lvl === "respond") needsInput++;
|
||||
if (lvl === "review" || lvl === "pending") reviewLoad++;
|
||||
}
|
||||
|
||||
return { map, totalWorkers, needsInput, reviewLoad };
|
||||
}, [sessions, prefixByProject, allPrefixes]);
|
||||
|
||||
const { totalWorkers: totalWorkerSessions, needsInput: needsInputCount, reviewLoad: reviewLoadCount } = sessionsByProject;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<>
|
||||
{mobileOpen && (
|
||||
<div className="sidebar-mobile-backdrop" onClick={onMobileClose} />
|
||||
)}
|
||||
<aside className={cn("project-sidebar project-sidebar--collapsed flex h-full w-[56px] flex-col items-center py-3", mobileOpen && "project-sidebar--mobile-open")}>
|
||||
<div className="flex flex-1 flex-col items-center gap-2">
|
||||
{projects.map((project) => {
|
||||
const entry = sessionsByProject.map.get(project.id);
|
||||
const health = entry ? computeProjectHealth(entry.all, prefixByProject, allPrefixes) : ("gray" as ProjectHealth);
|
||||
const isActive = activeProjectId === project.id;
|
||||
const initial = project.name.charAt(0).toUpperCase();
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
onClick={() => router.push(pathname + `?project=${encodeURIComponent(project.id)}`)}
|
||||
className={cn(
|
||||
"project-sidebar__collapsed-project",
|
||||
isActive && "project-sidebar__collapsed-project--active",
|
||||
)}
|
||||
title={project.name}
|
||||
>
|
||||
<span className="project-sidebar__avatar">{initial}</span>
|
||||
{health !== "gray" && (
|
||||
<span
|
||||
className={cn(
|
||||
"project-sidebar__health-indicator",
|
||||
health === "red" && "animate-[activity-pulse_2s_ease-in-out_infinite]",
|
||||
)}
|
||||
style={{ background: healthDotColor[health] }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onToggleCollapsed?.(); onMobileClose?.(); }}
|
||||
className="project-sidebar__collapsed-toggle mt-auto"
|
||||
aria-label="Show project sidebar"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
|
||||
<path d="M9 4.5v15M12 10l3 2-3 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</aside>
|
||||
{mobileOpen && <div className="sidebar-mobile-backdrop" onClick={onMobileClose} />}
|
||||
<aside
|
||||
className={cn(
|
||||
"project-sidebar project-sidebar--collapsed flex h-full flex-col",
|
||||
mobileOpen && "project-sidebar--mobile-open",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{mobileOpen && (
|
||||
<div className="sidebar-mobile-backdrop" onClick={onMobileClose} />
|
||||
)}
|
||||
<aside className={cn("project-sidebar flex h-full w-[244px] flex-col", mobileOpen && "project-sidebar--mobile-open")}>
|
||||
<div className="project-sidebar__header px-4 pb-3 pt-4">
|
||||
<div className="project-sidebar__eyebrow">Portfolio</div>
|
||||
<div className="project-sidebar__title-row">
|
||||
<div>
|
||||
<h2 className="project-sidebar__title">Projects</h2>
|
||||
<p className="project-sidebar__subtitle">Live project overview.</p>
|
||||
</div>
|
||||
<div className="project-sidebar__badge">{projects.length}</div>
|
||||
{mobileOpen && <div className="sidebar-mobile-backdrop" onClick={onMobileClose} />}
|
||||
<aside
|
||||
className={cn(
|
||||
"project-sidebar flex h-full flex-col",
|
||||
mobileOpen && "project-sidebar--mobile-open",
|
||||
)}
|
||||
>
|
||||
<div className="project-sidebar__compact-hdr">
|
||||
<span className="project-sidebar__sect-label">Projects</span>
|
||||
<button type="button" className="project-sidebar__add-btn" aria-label="New project">
|
||||
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="project-sidebar__summary">
|
||||
<div className="project-sidebar__metric">
|
||||
<span className="project-sidebar__metric-value">{totalWorkerSessions}</span>
|
||||
<span className="project-sidebar__metric-label">active</span>
|
||||
</div>
|
||||
<div className="project-sidebar__metric">
|
||||
<span
|
||||
className="project-sidebar__metric-value"
|
||||
style={{ color: "var(--color-status-attention)" }}
|
||||
>
|
||||
{reviewLoadCount}
|
||||
</span>
|
||||
<span className="project-sidebar__metric-label">review</span>
|
||||
</div>
|
||||
<div className="project-sidebar__metric">
|
||||
<span
|
||||
className="project-sidebar__metric-value"
|
||||
style={{ color: "var(--color-status-error)" }}
|
||||
>
|
||||
{needsInputCount}
|
||||
</span>
|
||||
<span className="project-sidebar__metric-label">blocked</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-2 pb-3">
|
||||
<button
|
||||
onClick={() => router.push(pathname + "?project=all")}
|
||||
className={cn(
|
||||
"project-sidebar__item mb-1 flex w-full items-center gap-2 px-2.5 py-[9px] text-left text-[12px] font-medium transition-colors",
|
||||
activeProjectId === undefined || activeProjectId === "all"
|
||||
? "project-sidebar__item--active text-[var(--color-accent)]"
|
||||
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="h-3.5 w-3.5 shrink-0 opacity-50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
All Projects
|
||||
</button>
|
||||
{/* Project tree */}
|
||||
<div className="project-sidebar__tree flex-1 overflow-y-auto overflow-x-hidden">
|
||||
{projects.map((project) => {
|
||||
const workerSessions = sessionsByProject.get(project.id) ?? [];
|
||||
const isExpanded = expandedProjects.has(project.id);
|
||||
const isActive = activeProjectId === project.id;
|
||||
const visibleSessions = workerSessions.filter(
|
||||
(s) => getAttentionLevel(s) !== "done",
|
||||
);
|
||||
const hasActiveSessions = visibleSessions.length > 0;
|
||||
|
||||
<div className="project-sidebar__divider mx-2 my-2" />
|
||||
|
||||
{projects.map((project) => {
|
||||
const entry = sessionsByProject.map.get(project.id);
|
||||
const projectSessions = entry?.all ?? [];
|
||||
const workerSessions = entry?.workers ?? [];
|
||||
const health = computeProjectHealth(projectSessions, prefixByProject, allPrefixes);
|
||||
const isExpanded = expandedProjects.has(project.id);
|
||||
const isActive = activeProjectId === project.id;
|
||||
|
||||
return (
|
||||
<div key={project.id} className="mb-0.5">
|
||||
{/* Project header */}
|
||||
<button
|
||||
onClick={() => handleProjectHeaderClick(project.id)}
|
||||
className={cn(
|
||||
"project-sidebar__item flex w-full items-center gap-2 px-2.5 py-[9px] text-left text-[12px] font-medium transition-colors",
|
||||
isActive
|
||||
? "project-sidebar__item--active text-[var(--color-accent)]"
|
||||
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
return (
|
||||
<div key={project.id} className="project-sidebar__project">
|
||||
{/* Project toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toggleExpand(project.id);
|
||||
navigate(`/?project=${encodeURIComponent(project.id)}`);
|
||||
}}
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 opacity-40 transition-transform duration-150",
|
||||
isExpanded && "rotate-90",
|
||||
"project-sidebar__proj-toggle",
|
||||
isActive && "project-sidebar__proj-toggle--active",
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-expanded={isExpanded}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
<HealthDot health={health} />
|
||||
<span className="min-w-0 flex-1 truncate">{project.name}</span>
|
||||
{workerSessions.length > 0 && (
|
||||
<span className="project-sidebar__count shrink-0 px-1.5 py-px text-[10px] tabular-nums text-[var(--color-text-tertiary)]">
|
||||
<svg
|
||||
className={cn(
|
||||
"project-sidebar__proj-chevron",
|
||||
isExpanded && "project-sidebar__proj-chevron--open",
|
||||
)}
|
||||
width="10"
|
||||
height="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
<span className="project-sidebar__proj-name">{project.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"project-sidebar__proj-badge",
|
||||
hasActiveSessions && "project-sidebar__proj-badge--active",
|
||||
)}
|
||||
>
|
||||
{workerSessions.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</button>
|
||||
|
||||
{isExpanded && workerSessions.length > 0 && (
|
||||
<div className="project-sidebar__children ml-3 py-0.5">
|
||||
{workerSessions.filter((s) => getAttentionLevel(s) !== "done").map((session) => {
|
||||
const level = getAttentionLevel(session);
|
||||
const isSessionActive = activeSessionId === session.id;
|
||||
const title = getSessionTitle(session);
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`${pathname}?project=${encodeURIComponent(project.id)}&session=${encodeURIComponent(session.id)}`,
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
router.push(
|
||||
`${pathname}?project=${encodeURIComponent(project.id)}&session=${encodeURIComponent(session.id)}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"project-sidebar__session group flex w-full cursor-pointer items-center gap-2 py-[6px] pl-3 pr-2 transition-colors",
|
||||
isSessionActive
|
||||
? "project-sidebar__session--active text-[var(--color-accent)]"
|
||||
: "text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]",
|
||||
)}
|
||||
>
|
||||
<SessionDot level={level} />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px]">{title}</span>
|
||||
<span className="project-sidebar__session-tone">
|
||||
{sessionToneLabel[level]}
|
||||
</span>
|
||||
<a
|
||||
href={`/sessions/${encodeURIComponent(session.id)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="project-sidebar__session-id shrink-0 font-mono text-[9px] hover:underline"
|
||||
title={session.id}
|
||||
>
|
||||
{session.id.slice(0, 8)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="border-t border-[var(--color-border-subtle)] p-2">
|
||||
<button type="button" onClick={() => { onToggleCollapsed?.(); onMobileClose?.(); }} className="project-sidebar__collapse-btn">
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
>
|
||||
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
|
||||
<path d="M9 4.5v15M15 10l-3 2 3 2" />
|
||||
</svg>
|
||||
Hide sidebar
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{/* Sessions */}
|
||||
{isExpanded && (
|
||||
<div className="project-sidebar__sessions">
|
||||
{visibleSessions.length > 0 ? (
|
||||
visibleSessions.map((session) => {
|
||||
const level = getAttentionLevel(session);
|
||||
const isSessionActive = activeSessionId === session.id;
|
||||
const title = session.branch ?? getSessionTitle(session);
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/sessions/${encodeURIComponent(session.id)}?project=${encodeURIComponent(project.id)}`,
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"project-sidebar__sess-row",
|
||||
isSessionActive && "project-sidebar__sess-row--active",
|
||||
)}
|
||||
aria-current={isSessionActive ? "page" : undefined}
|
||||
aria-label={`Open ${title}`}
|
||||
>
|
||||
<SessionDot level={level} />
|
||||
<span
|
||||
className={cn(
|
||||
"project-sidebar__sess-label",
|
||||
isSessionActive && "project-sidebar__sess-label--active",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span className="project-sidebar__sess-status">
|
||||
{LEVEL_LABELS[level]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="project-sidebar__empty">No active sessions</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="project-sidebar__footer">
|
||||
<ThemeToggle className="project-sidebar__theme-toggle" />
|
||||
<span className="project-sidebar__theme-label">Theme</span>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ interface PullRequestsPageProps {
|
|||
|
||||
const EMPTY_ORCHESTRATORS: DashboardOrchestratorLink[] = [];
|
||||
|
||||
type PRFilterValue = "all" | "open" | "merged" | "closed";
|
||||
|
||||
function getSectionLabel(filter: PRFilterValue): string {
|
||||
if (filter === "open") return "Open PRs";
|
||||
if (filter === "merged") return "Merged PRs";
|
||||
if (filter === "closed") return "Closed PRs";
|
||||
return "All PRs";
|
||||
}
|
||||
|
||||
export function PullRequestsPage({
|
||||
initialSessions,
|
||||
projectId,
|
||||
|
|
@ -64,26 +73,33 @@ export function PullRequestsPage({
|
|||
: null,
|
||||
[orchestratorLinks, projectId],
|
||||
);
|
||||
const openPRs = useMemo(() => {
|
||||
const [prFilter, setPrFilter] = useState<PRFilterValue>("all");
|
||||
|
||||
const allPRs = useMemo(() => {
|
||||
return sessions
|
||||
.filter(
|
||||
(session): session is DashboardSession & { pr: DashboardPR } => session.pr?.state === "open",
|
||||
)
|
||||
.filter((session): session is DashboardSession & { pr: DashboardPR } => !!session.pr)
|
||||
.map((session) => session.pr)
|
||||
.sort((a, b) => a.number - b.number);
|
||||
.sort((a, b) => b.number - a.number);
|
||||
}, [sessions]);
|
||||
|
||||
const openPRs = useMemo(() => allPRs.filter((pr) => pr.state === "open"), [allPRs]);
|
||||
const mergedPRs = useMemo(() => allPRs.filter((pr) => pr.state === "merged"), [allPRs]);
|
||||
const closedPRs = useMemo(() => allPRs.filter((pr) => pr.state === "closed"), [allPRs]);
|
||||
const dashboardHref = getProjectScopedHref("/", projectId);
|
||||
const prsHref = getProjectScopedHref("/prs", projectId);
|
||||
const orchestratorHref = currentProjectOrchestrator
|
||||
? `/sessions/${encodeURIComponent(currentProjectOrchestrator.id)}`
|
||||
: null;
|
||||
const activeMobilePRs = prFilter === "open" ? openPRs : prFilter === "merged" ? mergedPRs : prFilter === "closed" ? closedPRs : allPRs;
|
||||
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="dashboard-shell flex h-screen">
|
||||
<div
|
||||
className={`dashboard-shell flex h-screen${!isMobile && sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
|
||||
>
|
||||
{showSidebar ? (
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
|
|
@ -98,98 +114,269 @@ export function PullRequestsPage({
|
|||
) : null}
|
||||
<div className="dashboard-main flex-1 overflow-y-auto px-4 py-4 md:px-7 md:py-6">
|
||||
<DynamicFavicon sseAttentionLevels={sseAttentionLevels} projectName={projectName ? `${projectName} PRs` : "Pull Requests"} />
|
||||
<section className="dashboard-hero mb-5">
|
||||
<div className="dashboard-hero__backdrop" />
|
||||
<div className="dashboard-hero__content">
|
||||
{showSidebar ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-menu-toggle"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="dashboard-hero__primary">
|
||||
<div className="dashboard-hero__heading">
|
||||
<div>
|
||||
<h1 className="dashboard-title">{projectName ? `${projectName} PRs` : "Pull Requests"}</h1>
|
||||
<p className="dashboard-subtitle">
|
||||
Open pull requests created by agents{allProjectsView ? " across all projects" : " in this project"}.
|
||||
</p>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<section className="mobile-pr-page-header">
|
||||
<div className="mobile-pr-page-header__top">
|
||||
<div className="mobile-pr-page-header__title-row">
|
||||
{showSidebar ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-menu-toggle"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<h1 className="mobile-pr-page-header__title">
|
||||
{projectName ? `${projectName} PRs` : "Pull Requests"}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="dashboard-stat-cards dashboard-stat-cards--persist-mobile">
|
||||
<div className="dashboard-stat-card">
|
||||
<span className="dashboard-stat-card__value">{openPRs.length}</span>
|
||||
<span className="dashboard-stat-card__label">Open PRs</span>
|
||||
<span className="dashboard-stat-card__meta">
|
||||
{allProjectsView ? "Across all projects" : "In this project"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-hero__meta">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="mobile-pr-page-header__meta">
|
||||
<span className="mobile-pr-page-header__count">{allPRs.length}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<p className="mobile-pr-page-header__subtitle">
|
||||
Open pull requests created by agents{allProjectsView ? " across projects" : " in this project"}.
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<section className="dashboard-hero mb-5">
|
||||
<div className="dashboard-hero__backdrop" />
|
||||
<div className="dashboard-hero__content">
|
||||
{showSidebar ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-menu-toggle"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="dashboard-hero__primary">
|
||||
<div className="dashboard-hero__heading">
|
||||
<div>
|
||||
<h1 className="dashboard-title">{projectName ? `${projectName} PRs` : "Pull Requests"}</h1>
|
||||
<p className="dashboard-subtitle">
|
||||
Open pull requests created by agents{allProjectsView ? " across all projects" : " in this project"}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-stat-cards dashboard-stat-cards--persist-mobile">
|
||||
<div className="dashboard-stat-card">
|
||||
<span className="dashboard-stat-card__value">{openPRs.length}</span>
|
||||
<span className="dashboard-stat-card__label">Open PRs</span>
|
||||
<span className="dashboard-stat-card__meta">
|
||||
{allProjectsView ? "Across all projects" : "In this project"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-hero__meta">
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mx-auto max-w-[900px]">
|
||||
<h2 className="mb-3 px-1 text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
|
||||
Pull Requests
|
||||
</h2>
|
||||
{openPRs.length === 0 ? (
|
||||
<div className="border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] px-4 py-6 text-[12px] text-[var(--color-text-secondary)]">
|
||||
No open pull requests right now.
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div className="mobile-pr-list">
|
||||
{openPRs.map((pr) => (
|
||||
<PRCard key={`${pr.owner}/${pr.repo}-${pr.number}`} pr={pr} />
|
||||
))}
|
||||
{/* Filter tabs */}
|
||||
<div className={isMobile ? "mobile-pr-filter-tabs" : "mb-4 flex items-center gap-1.5"}>
|
||||
{(
|
||||
[
|
||||
{ value: "all", label: "All", count: allPRs.length },
|
||||
{ value: "open", label: "Open", count: openPRs.length },
|
||||
{ value: "merged", label: "Merged", count: mergedPRs.length },
|
||||
{ value: "closed", label: "Closed", count: closedPRs.length },
|
||||
] as const
|
||||
).map(({ value, label, count }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setPrFilter(value)}
|
||||
className={
|
||||
isMobile
|
||||
? "mobile-pr-filter-tab"
|
||||
: [
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11px] font-semibold transition-colors",
|
||||
prFilter === value
|
||||
? "border-[var(--color-border-default)] bg-[var(--color-bg-elevated)] text-[var(--color-text-primary)]"
|
||||
: "border-transparent bg-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]",
|
||||
].join(" ")
|
||||
}
|
||||
data-active={isMobile ? String(prFilter === value) : undefined}
|
||||
>
|
||||
{label}
|
||||
<span className={isMobile ? "mobile-pr-filter-tab__count" : "rounded-full bg-[var(--color-chip-bg)] px-1.5 py-px text-[9.5px] font-mono text-[var(--color-text-muted)]"}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<div className="mobile-pr-mobile-layout">
|
||||
{prFilter === "all" ? (
|
||||
<>
|
||||
{openPRs.length > 0 && (
|
||||
<section className="mobile-pr-group" aria-label="Open pull requests">
|
||||
<div className="mobile-pr-section-header">
|
||||
<span>Open</span>
|
||||
<span>{openPRs.length}</span>
|
||||
</div>
|
||||
<div className="mobile-pr-list">
|
||||
{openPRs.map((pr) => (
|
||||
<PRCard key={`${pr.owner}/${pr.repo}-${pr.number}`} pr={pr} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{mergedPRs.length > 0 && (
|
||||
<section className="mobile-pr-group" aria-label="Merged pull requests">
|
||||
<div className="mobile-pr-section-header">
|
||||
<span>Merged</span>
|
||||
<span>{mergedPRs.length}</span>
|
||||
</div>
|
||||
<div className="mobile-pr-list">
|
||||
{mergedPRs.map((pr) => (
|
||||
<PRCard key={`${pr.owner}/${pr.repo}-${pr.number}`} pr={pr} muted />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{closedPRs.length > 0 && (
|
||||
<section className="mobile-pr-group" aria-label="Closed pull requests">
|
||||
<div className="mobile-pr-section-header">
|
||||
<span>Closed</span>
|
||||
<span>{closedPRs.length}</span>
|
||||
</div>
|
||||
<div className="mobile-pr-list">
|
||||
{closedPRs.map((pr) => (
|
||||
<PRCard key={`${pr.owner}/${pr.repo}-${pr.number}`} pr={pr} muted />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{allPRs.length === 0 && (
|
||||
<div className="mobile-pr-empty">
|
||||
No pull requests yet.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<section className="mobile-pr-group" aria-label={getSectionLabel(prFilter)}>
|
||||
<div className="mobile-pr-section-header">
|
||||
<span>{getSectionLabel(prFilter).replace(" PRs", "")}</span>
|
||||
<span>{activeMobilePRs.length}</span>
|
||||
</div>
|
||||
<div className="mobile-pr-list">
|
||||
{activeMobilePRs.length > 0 ? (
|
||||
activeMobilePRs.map((pr) => (
|
||||
<PRCard key={`${pr.owner}/${pr.repo}-${pr.number}`} pr={pr} muted={prFilter !== "open"} />
|
||||
))
|
||||
) : (
|
||||
<div className="mobile-pr-empty">No pull requests in this view.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden border border-[var(--color-border-default)]">
|
||||
<div className="overflow-hidden rounded-[7px] border border-[var(--color-border-subtle)]">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--color-border-muted)]">
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
<tr className="border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)]">
|
||||
<th className="px-3 py-2 text-left text-[10.5px] font-mono font-500 uppercase tracking-[0.05em] text-[var(--color-text-muted)]">
|
||||
PR
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
<th className="px-3 py-2 text-left text-[10.5px] font-mono font-500 uppercase tracking-[0.05em] text-[var(--color-text-muted)]">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
<th className="px-3 py-2 text-left text-[10.5px] font-mono font-500 uppercase tracking-[0.05em] text-[var(--color-text-muted)]">
|
||||
Size
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
<th className="px-3 py-2 text-left text-[10.5px] font-mono font-500 uppercase tracking-[0.05em] text-[var(--color-text-muted)]">
|
||||
CI
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
<th className="px-3 py-2 text-left text-[10.5px] font-mono font-500 uppercase tracking-[0.05em] text-[var(--color-text-muted)]">
|
||||
Review
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Unresolved
|
||||
<th className="px-3 py-2 text-left text-[10.5px] font-mono font-500 uppercase tracking-[0.05em] text-[var(--color-text-muted)]">
|
||||
Threads
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{openPRs.map((pr) => (
|
||||
<PRTableRow key={`${pr.owner}/${pr.repo}-${pr.number}`} pr={pr} />
|
||||
))}
|
||||
{(prFilter === "all" || prFilter === "open") && openPRs.length > 0 && (
|
||||
<>
|
||||
{prFilter === "all" && (
|
||||
<tr>
|
||||
<td colSpan={6} className="border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-base)] px-3 py-1.5 text-[9.5px] font-mono font-semibold uppercase tracking-[0.06em] text-[var(--color-text-muted)]">
|
||||
Open
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{openPRs.map((pr) => (
|
||||
<PRTableRow key={`${pr.owner}/${pr.repo}-${pr.number}`} pr={pr} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{(prFilter === "all" || prFilter === "merged") && mergedPRs.length > 0 && (
|
||||
<>
|
||||
{prFilter === "all" && (
|
||||
<tr>
|
||||
<td colSpan={6} className="border-b border-t border-[var(--color-border-subtle)] bg-[var(--color-bg-base)] px-3 py-1.5 text-[9.5px] font-mono font-semibold uppercase tracking-[0.06em] text-[var(--color-text-muted)]">
|
||||
Merged
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{mergedPRs.map((pr) => (
|
||||
<PRTableRow key={`${pr.owner}/${pr.repo}-${pr.number}`} pr={pr} muted />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{(prFilter === "all" || prFilter === "closed") && closedPRs.length > 0 && (
|
||||
<>
|
||||
{prFilter === "all" && (
|
||||
<tr>
|
||||
<td colSpan={6} className="border-b border-t border-[var(--color-border-subtle)] bg-[var(--color-bg-base)] px-3 py-1.5 text-[9.5px] font-mono font-semibold uppercase tracking-[0.06em] text-[var(--color-text-muted)]">
|
||||
Closed
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{closedPRs.map((pr) => (
|
||||
<PRTableRow key={`${pr.owner}/${pr.repo}-${pr.number}`} pr={pr} muted />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{allPRs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-6 text-[12px] text-[var(--color-text-secondary)]">
|
||||
No pull requests yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
import { cn } from "@/lib/cn";
|
||||
import { getSessionTitle } from "@/lib/format";
|
||||
import { CICheckList } from "./CIBadge";
|
||||
import { ActivityDot } from "./ActivityDot";
|
||||
import { getSizeLabel } from "./PRStatus";
|
||||
|
||||
interface SessionCardProps {
|
||||
|
|
@ -98,6 +97,7 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
const [failedAction, setFailedAction] = useState<string | null>(null);
|
||||
const [sendingQuickReply, setSendingQuickReply] = useState<string | null>(null);
|
||||
const [sentQuickReply, setSentQuickReply] = useState<string | null>(null);
|
||||
const [killConfirming, setKillConfirming] = useState(false);
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const actionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const quickReplyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
|
@ -166,23 +166,40 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
const isRestorable = isTerminal && session.status !== "merged";
|
||||
|
||||
const title = getSessionTitle(session);
|
||||
const footerStatus = getFooterStatusLabel(session, level, Boolean(isReadyToMerge));
|
||||
const visiblePassingChecks = !rateLimited && pr && !prUnenriched
|
||||
? pr.ciChecks.filter((check) => check.status === "passed").slice(0, 3)
|
||||
: [];
|
||||
const isDone = level === "done";
|
||||
const secondaryText = session.issueLabel
|
||||
? `${session.issueLabel}${session.issueTitle ? ` · ${session.issueTitle}` : ""}`
|
||||
: session.issueTitle ?? (session.summary && session.summary !== title ? session.summary : null);
|
||||
: (session.issueTitle ??
|
||||
(session.summary && session.summary !== title ? session.summary : null));
|
||||
const cardFrameClass = isReadyToMerge
|
||||
? "session-card--merge-frame"
|
||||
: alerts.length > 0
|
||||
? "session-card--alert-frame"
|
||||
: "session-card--fixed";
|
||||
const dynamicCardStyle =
|
||||
alerts.length > 0
|
||||
? {
|
||||
minHeight: `${242 + Math.max(0, alerts.length - 2) * 44}px`,
|
||||
}
|
||||
: isReadyToMerge
|
||||
? { minHeight: "264px" }
|
||||
: undefined;
|
||||
const accentClass = isReadyToMerge
|
||||
? "session-card--accent-merge"
|
||||
: level === "working"
|
||||
? "session-card--accent-working"
|
||||
: level === "respond"
|
||||
? "session-card--accent-respond"
|
||||
: level === "review" || level === "pending"
|
||||
? "session-card--accent-attention"
|
||||
: "session-card--accent-default";
|
||||
|
||||
const handleKillClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
if (!killConfirming) {
|
||||
setKillConfirming(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setKillConfirming(false);
|
||||
onKill?.(session.id);
|
||||
};
|
||||
|
||||
/* ── Done card variant ──────────────────────────────────────────── */
|
||||
if (isDone) {
|
||||
|
|
@ -231,10 +248,7 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
|
||||
{/* Row 2: Title */}
|
||||
<div className="px-3.5 pb-2">
|
||||
<p
|
||||
className="text-[13px] font-semibold leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:2] overflow-hidden"
|
||||
style={{ color: "var(--done-title-color)" }}
|
||||
>
|
||||
<p className="session-card-done__title text-[13px] font-semibold leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:2] overflow-hidden">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -267,15 +281,20 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
#{pr.number}
|
||||
</a>
|
||||
)}
|
||||
{pr && !rateLimited && (prUnenriched ? (
|
||||
<span className="inline-block h-[14px] w-16 animate-pulse rounded-full bg-[var(--color-bg-subtle)]" />
|
||||
) : (
|
||||
<span className="done-meta-chip font-[var(--font-mono)]">
|
||||
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
|
||||
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>{" "}
|
||||
{getSizeLabel(pr.additions, pr.deletions)}
|
||||
</span>
|
||||
))}
|
||||
{pr &&
|
||||
!rateLimited &&
|
||||
(prUnenriched ? (
|
||||
<span className="inline-block h-[14px] w-16 animate-pulse rounded-full bg-[var(--color-bg-subtle)]" />
|
||||
) : (
|
||||
<span className="done-meta-chip font-[var(--font-mono)]">
|
||||
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
|
||||
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>{" "}
|
||||
{getSizeLabel(pr.additions, pr.deletions)}
|
||||
<span className="sr-only">
|
||||
{`+${pr.additions} -${pr.deletions} ${getSizeLabel(pr.additions, pr.deletions)}`}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Expandable detail panel */}
|
||||
|
|
@ -393,24 +412,37 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
);
|
||||
}
|
||||
|
||||
const cardDotTone = (() => {
|
||||
if (isTerminal) return "exited";
|
||||
if (isReadyToMerge || level === "merge" || level === "review") return "ready";
|
||||
if (level === "respond") return "waiting";
|
||||
if (level === "pending") return "idle";
|
||||
if (level === "working") return "working";
|
||||
return "idle";
|
||||
})();
|
||||
|
||||
/* ── Standard card (non-done) ────────────────────────────────────── */
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"session-card border",
|
||||
"session-card kanban-card-enter border",
|
||||
cardFrameClass,
|
||||
"hover:border-[var(--color-border-strong)]",
|
||||
`card-glow-${level}`,
|
||||
isReadyToMerge
|
||||
? "card-merge-ready border-[color-mix(in_srgb,var(--color-status-ready)_30%,transparent)]"
|
||||
: "border-[var(--color-border-default)]",
|
||||
accentClass,
|
||||
isReadyToMerge && "card-merge-ready",
|
||||
)}
|
||||
style={dynamicCardStyle}
|
||||
>
|
||||
{/* Header row: dot + session ID + terminal link */}
|
||||
<div className="session-card__header flex items-center gap-2 px-4 pt-4 pb-2">
|
||||
{isReadyToMerge ? <ActivityDot activity="ready" /> : <ActivityDot activity={session.activity} />}
|
||||
<span className="font-[var(--font-mono)] text-[11px] tracking-wide text-[var(--color-text-muted)]">
|
||||
<div className="session-card__header">
|
||||
<span
|
||||
className={cn(
|
||||
"card__adot",
|
||||
cardDotTone === "working" && "card__adot--working",
|
||||
cardDotTone === "ready" && "card__adot--ready",
|
||||
cardDotTone === "idle" && "card__adot--idle",
|
||||
cardDotTone === "waiting" && "card__adot--waiting",
|
||||
cardDotTone === "exited" && "card__adot--exited",
|
||||
)}
|
||||
/>
|
||||
<span className="card__id">
|
||||
{session.id}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
|
|
@ -439,7 +471,7 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
<a
|
||||
href={`/sessions/${encodeURIComponent(session.id)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="session-card__control inline-flex items-center justify-center gap-1.5 border border-[var(--color-border-default)] bg-[var(--color-bg-subtle)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-accent)] hover:text-[var(--color-accent)] hover:no-underline"
|
||||
className="session-card__control session-card__terminal-link"
|
||||
>
|
||||
<svg
|
||||
className="session-card__control-icon"
|
||||
|
|
@ -458,58 +490,69 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
</div>
|
||||
|
||||
<div className="session-card__body flex min-h-0 flex-1 flex-col">
|
||||
{/* Title — its own row, bigger, can wrap */}
|
||||
<div className="session-card__title-wrap px-4 pb-2">
|
||||
<p
|
||||
className={cn(
|
||||
"session-card__title leading-snug [display:-webkit-box] [-webkit-box-orient:vertical] overflow-hidden",
|
||||
level === "working"
|
||||
? "text-[13px] font-medium text-[var(--color-text-secondary)]"
|
||||
: "text-[14px] font-semibold text-[var(--color-text-primary)]",
|
||||
)}
|
||||
>
|
||||
<div className="card__title-wrap">
|
||||
<p className="card__title">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Meta row: branch + PR# + diff size (simplified for merge-ready) */}
|
||||
<div className="session-card__meta flex flex-wrap items-center gap-1.5 px-4 pb-2">
|
||||
<div className="card__meta">
|
||||
{session.branch && (
|
||||
<span className="font-[var(--font-mono)] text-[10px] text-[var(--color-text-muted)]">
|
||||
<span className="card__branch">
|
||||
{session.branch}
|
||||
</span>
|
||||
)}
|
||||
{session.branch && pr ? (
|
||||
<span className="card__meta-sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
) : null}
|
||||
{pr && (
|
||||
<a
|
||||
href={pr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-[var(--font-mono)] text-[11px] font-bold text-[var(--color-text-primary)] underline-offset-2 hover:underline"
|
||||
className="card__pr"
|
||||
>
|
||||
#{pr.number}
|
||||
</a>
|
||||
)}
|
||||
{pr && !rateLimited && (prUnenriched ? (
|
||||
<span className="inline-block h-[14px] w-16 animate-pulse rounded-full bg-[var(--color-bg-subtle)]" />
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-full bg-[var(--color-chip-bg)] px-2 py-0.5 font-[var(--font-mono)] text-[10px] font-semibold text-[var(--color-text-muted)]">
|
||||
+{pr.additions} -{pr.deletions} {getSizeLabel(pr.additions, pr.deletions)}
|
||||
</span>
|
||||
))}
|
||||
{pr &&
|
||||
!rateLimited &&
|
||||
(prUnenriched ? (
|
||||
<span className="inline-block h-[14px] w-16 animate-pulse rounded-full bg-[var(--color-bg-subtle)]" />
|
||||
) : (
|
||||
<span className="card__diff inline-flex items-center">
|
||||
<span className="card__diff-add">+{pr.additions}</span>{" "}
|
||||
<span className="card__diff-del">-{pr.deletions}</span>{" "}
|
||||
<span className="card__diff-size">{getSizeLabel(pr.additions, pr.deletions)}</span>
|
||||
<span className="sr-only">
|
||||
{`+${pr.additions} -${pr.deletions} ${getSizeLabel(pr.additions, pr.deletions)}`}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{secondaryText && (
|
||||
<div className="px-4 pb-2">
|
||||
<p className="session-card__secondary text-[11px] text-[var(--color-text-muted)]">
|
||||
{secondaryText}
|
||||
</p>
|
||||
<div className="px-[10px] pb-[5px]">
|
||||
{level === "merge" || isReadyToMerge ? (
|
||||
<p className="session-card__secondary session-card__secondary--merge">
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
<span>{secondaryText}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="session-card__secondary">
|
||||
{secondaryText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate limited indicator */}
|
||||
{rateLimited && pr?.state === "open" && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="px-[10px] pb-[5px]">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
|
||||
<svg
|
||||
className="h-3 w-3 text-[var(--color-text-tertiary)]"
|
||||
|
|
@ -526,28 +569,33 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
</div>
|
||||
)}
|
||||
|
||||
{visiblePassingChecks.length > 0 && (
|
||||
<div className="card__ci">
|
||||
{visiblePassingChecks.map((check) => (
|
||||
<span key={check.name} className="ci-chip ci-chip--pass">
|
||||
<svg width="8" height="8" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
{check.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!rateLimited && alerts.length > 0 && (
|
||||
<div className="session-card__actions px-4 pb-2 pt-0.5">
|
||||
<div className="session-card__alert-grid">
|
||||
{alerts.slice(0, 3).map((alert) => (
|
||||
<span
|
||||
key={alert.key}
|
||||
className="session-card__alert-pill inline-flex items-stretch overflow-hidden border"
|
||||
style={{
|
||||
borderColor:
|
||||
alert.borderColor ?? alert.color ?? "var(--color-border-default)",
|
||||
}}
|
||||
>
|
||||
<div className="card__alerts flex flex-col">
|
||||
{alerts.slice(0, 3).map((alert) => (
|
||||
<div
|
||||
key={alert.key}
|
||||
className={cn("alert-row", `alert-row--${alert.type}`)}
|
||||
>
|
||||
<span className="alert-row__icon">{alert.icon}</span>
|
||||
<span className="alert-row__text">
|
||||
<a
|
||||
href={alert.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate whitespace-nowrap px-2 py-0.5 font-[var(--font-mono)] text-[11px] font-medium !underline [text-decoration-skip-ink:none] [text-underline-offset:2px] hover:brightness-125",
|
||||
alert.className,
|
||||
)}
|
||||
style={alert.color ? { color: alert.color } : undefined}
|
||||
>
|
||||
{alert.count !== undefined && (
|
||||
<>
|
||||
|
|
@ -555,56 +603,107 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
</>
|
||||
)}
|
||||
{alert.label}
|
||||
{alert.notified && (
|
||||
<span className="ml-1 opacity-60" title="Agent has been notified"> · notified</span>
|
||||
)}
|
||||
</a>
|
||||
{alert.actionLabel && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleAction(alert.key, alert.actionMessage ?? "");
|
||||
}}
|
||||
disabled={sendingAction === alert.key}
|
||||
className={cn(
|
||||
"border-l px-2 py-0.5 font-[var(--font-mono)] text-[11px] font-medium transition-colors disabled:opacity-50",
|
||||
failedAction === alert.key &&
|
||||
"bg-[var(--color-tint-red)] text-[var(--color-status-error)]",
|
||||
alert.actionClassName,
|
||||
)}
|
||||
style={{
|
||||
borderColor:
|
||||
alert.borderColor ?? alert.color ?? "var(--color-border-default)",
|
||||
}}
|
||||
>
|
||||
{sendingAction === alert.key
|
||||
? "sent!"
|
||||
: failedAction === alert.key
|
||||
? "failed"
|
||||
: alert.actionLabel}
|
||||
</button>
|
||||
{alert.notified && (
|
||||
<span className="alert-row__notified" title="Agent has been notified">
|
||||
{" "}· notified
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{alert.actionLabel && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleAction(alert.key, alert.actionMessage ?? "");
|
||||
}}
|
||||
disabled={sendingAction === alert.key}
|
||||
className="alert-row__action"
|
||||
>
|
||||
{sendingAction === alert.key
|
||||
? "sent!"
|
||||
: failedAction === alert.key
|
||||
? "failed"
|
||||
: alert.actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{level === "respond" && (
|
||||
<div className="quick-reply" onClick={(e) => e.stopPropagation()}>
|
||||
{session.summary && !session.summaryIsFallback && (
|
||||
<div className="card__agent-msg">
|
||||
<svg className="card__agent-msg-icon" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
<span>{session.summary}</span>
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
href={`/sessions/${encodeURIComponent(session.id)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="card__view-context"
|
||||
>
|
||||
View current context →
|
||||
</a>
|
||||
<div className="card__presets">
|
||||
<button
|
||||
className="card__preset"
|
||||
onClick={() => void handleQuickReply("continue")}
|
||||
disabled={sendingQuickReply !== null}
|
||||
>
|
||||
{sendingQuickReply === "continue"
|
||||
? "Sending..."
|
||||
: sentQuickReply === "continue"
|
||||
? "Sent"
|
||||
: "Continue"}
|
||||
</button>
|
||||
<button
|
||||
className="card__preset"
|
||||
onClick={() => void handleQuickReply("abort")}
|
||||
disabled={sendingQuickReply !== null}
|
||||
>
|
||||
{sendingQuickReply === "abort"
|
||||
? "Sending..."
|
||||
: sentQuickReply === "abort"
|
||||
? "Sent"
|
||||
: "Abort"}
|
||||
</button>
|
||||
<button
|
||||
className="card__preset"
|
||||
onClick={() => void handleQuickReply("skip")}
|
||||
disabled={sendingQuickReply !== null}
|
||||
>
|
||||
{sendingQuickReply === "skip"
|
||||
? "Sending..."
|
||||
: sentQuickReply === "skip"
|
||||
? "Sent"
|
||||
: "Skip"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="card__reply-wrap">
|
||||
<textarea
|
||||
className="card__reply"
|
||||
placeholder={sendingQuickReply !== null ? "Sending..." : "Type a reply... (Enter to send)"}
|
||||
aria-label="Type a reply to the agent"
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
void handleReplyKeyDown(e);
|
||||
}}
|
||||
rows={1}
|
||||
disabled={sendingQuickReply !== null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="session-card__footer mt-auto flex items-center justify-between gap-2 border-t border-[var(--color-border-subtle)] px-4 py-2.5">
|
||||
{session.issueUrl ? (
|
||||
<a
|
||||
href={session.issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 truncate text-[11px] text-[var(--color-accent)] hover:underline"
|
||||
>
|
||||
{session.issueLabel || session.issueUrl}
|
||||
</a>
|
||||
) : (
|
||||
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{session.activity ?? session.status}
|
||||
</span>
|
||||
)}
|
||||
<div className="session-card__footer">
|
||||
<span className="card__status min-w-0 truncate">
|
||||
{footerStatus}
|
||||
</span>
|
||||
|
||||
{isReadyToMerge && pr ? (
|
||||
<button
|
||||
|
|
@ -612,7 +711,7 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
e.stopPropagation();
|
||||
onMerge?.(pr.number);
|
||||
}}
|
||||
className="session-card__control session-card__merge-control inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 border px-2.5 py-1 text-[11px] transition-colors"
|
||||
className="session-card__control session-card__merge-control"
|
||||
>
|
||||
<svg
|
||||
className="session-card__control-icon"
|
||||
|
|
@ -625,90 +724,41 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
|
|||
<circle cx="18" cy="18" r="2" />
|
||||
<circle cx="18" cy="6" r="2" />
|
||||
<path d="M8 6h5a3 3 0 0 1 3 3v7" />
|
||||
<path d="M8 6h5a3 3 0 0 0 3-3V8" />
|
||||
</svg>
|
||||
merge
|
||||
Merge PR #{pr.number}
|
||||
</button>
|
||||
) : !isTerminal && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKill?.(session.id);
|
||||
}}
|
||||
aria-label="Terminate session"
|
||||
className="session-card__control session-card__terminate inline-flex shrink-0 cursor-pointer items-center justify-center border border-[color-mix(in_srgb,var(--color-status-error)_35%,transparent)] px-2.5 py-1 text-[11px] text-[var(--color-status-error)] transition-colors hover:border-[var(--color-status-error)] hover:bg-[var(--color-tint-red)]"
|
||||
>
|
||||
<svg
|
||||
className="session-card__control-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
) : (
|
||||
!isTerminal && (
|
||||
<button
|
||||
onClick={handleKillClick}
|
||||
onMouseLeave={() => setKillConfirming(false)}
|
||||
onBlur={() => setKillConfirming(false)}
|
||||
aria-label={killConfirming ? "Confirm terminate session" : "Terminate session"}
|
||||
className={cn(
|
||||
"session-card__control session-card__terminate btn--danger",
|
||||
killConfirming && "is-confirming",
|
||||
)}
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
</button>
|
||||
{killConfirming ? (
|
||||
<span className="font-mono text-[10px] font-semibold tracking-[0.04em]">kill?</span>
|
||||
) : (
|
||||
<svg
|
||||
className="session-card__control-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{level === "respond" && (
|
||||
<div className="quick-reply" onClick={(e) => e.stopPropagation()}>
|
||||
{session.summary && !session.summaryIsFallback && (
|
||||
<p className="quick-reply__summary">{session.summary}</p>
|
||||
)}
|
||||
<div className="quick-reply__presets">
|
||||
<button
|
||||
className="quick-reply__preset-btn"
|
||||
onClick={() => void handleQuickReply("continue")}
|
||||
disabled={sendingQuickReply !== null}
|
||||
>
|
||||
{sendingQuickReply === "continue"
|
||||
? "Sending..."
|
||||
: sentQuickReply === "continue"
|
||||
? "Sent"
|
||||
: "Continue"}
|
||||
</button>
|
||||
<button
|
||||
className="quick-reply__preset-btn"
|
||||
onClick={() => void handleQuickReply("abort")}
|
||||
disabled={sendingQuickReply !== null}
|
||||
>
|
||||
{sendingQuickReply === "abort"
|
||||
? "Sending..."
|
||||
: sentQuickReply === "abort"
|
||||
? "Sent"
|
||||
: "Abort"}
|
||||
</button>
|
||||
<button
|
||||
className="quick-reply__preset-btn"
|
||||
onClick={() => void handleQuickReply("skip")}
|
||||
disabled={sendingQuickReply !== null}
|
||||
>
|
||||
{sendingQuickReply === "skip"
|
||||
? "Sending..."
|
||||
: sentQuickReply === "skip"
|
||||
? "Sent"
|
||||
: "Skip"}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="quick-reply__input"
|
||||
placeholder={sendingQuickReply !== null ? "Sending..." : "Type a reply..."}
|
||||
aria-label="Type a reply to the agent"
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
void handleReplyKeyDown(e);
|
||||
}}
|
||||
rows={1}
|
||||
disabled={sendingQuickReply !== null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -725,18 +775,29 @@ function areSessionCardPropsEqual(prev: SessionCardProps, next: SessionCardProps
|
|||
|
||||
export const SessionCard = memo(SessionCardView, areSessionCardPropsEqual);
|
||||
|
||||
function getFooterStatusLabel(
|
||||
session: DashboardSession,
|
||||
level: ReturnType<typeof getAttentionLevel>,
|
||||
isReadyToMerge: boolean,
|
||||
): string {
|
||||
if (isReadyToMerge || level === "merge") return "mergeable";
|
||||
if (level === "respond") return "waiting_input";
|
||||
if (session.status === "ci_failed") return "ci_failed";
|
||||
if (level === "review") return "review_pending";
|
||||
if (level === "working") return "working";
|
||||
return session.status;
|
||||
}
|
||||
|
||||
interface Alert {
|
||||
key: string;
|
||||
type: "ci" | "changes" | "review" | "conflict" | "comment";
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
className: string;
|
||||
color?: string;
|
||||
borderColor?: string;
|
||||
url: string;
|
||||
count?: number;
|
||||
notified?: boolean;
|
||||
actionLabel?: string;
|
||||
actionMessage?: string;
|
||||
actionClassName?: string;
|
||||
}
|
||||
|
||||
function getAlerts(session: DashboardSession): Alert[] {
|
||||
|
|
@ -765,36 +826,33 @@ function getAlerts(session: DashboardSession): Alert[] {
|
|||
// Lifecycle says ci_failed but PR enrichment hasn't caught up — show generic alert
|
||||
alerts.push({
|
||||
key: "ci-fail",
|
||||
type: "ci",
|
||||
icon: "\u2717",
|
||||
label: "CI failing",
|
||||
className: "",
|
||||
color: "var(--color-alert-ci)",
|
||||
borderColor: "var(--color-alert-ci)",
|
||||
url: pr.url + "/checks",
|
||||
notified: Boolean(meta["lastCIFailureDispatchHash"]),
|
||||
actionLabel: "ask to fix",
|
||||
actionLabel: "Ask to fix",
|
||||
actionMessage: `Please fix the failing CI checks on ${pr.url}`,
|
||||
actionClassName: "bg-[var(--color-alert-ci-bg)] text-white hover:brightness-110",
|
||||
});
|
||||
} else if (failCount === 0) {
|
||||
alerts.push({
|
||||
key: "ci-unknown",
|
||||
type: "ci",
|
||||
icon: "?",
|
||||
label: "CI unknown",
|
||||
className: "",
|
||||
color: "var(--color-alert-ci-unknown)",
|
||||
url: pr.url + "/checks",
|
||||
});
|
||||
} else {
|
||||
alerts.push({
|
||||
key: "ci-fail",
|
||||
label: `${failCount} CI check${failCount > 1 ? "s" : ""} failing`,
|
||||
className: "",
|
||||
color: "var(--color-alert-ci)",
|
||||
borderColor: "var(--color-alert-ci)",
|
||||
type: "ci",
|
||||
icon: "\u2717",
|
||||
count: failCount,
|
||||
label: `check${failCount > 1 ? "s" : ""} failing`,
|
||||
url: failedCheck?.url ?? pr.url + "/checks",
|
||||
notified: Boolean(meta["lastCIFailureDispatchHash"]),
|
||||
actionLabel: "ask to fix",
|
||||
actionLabel: "Ask to fix",
|
||||
actionMessage: `Please fix the failing CI checks on ${pr.url}`,
|
||||
actionClassName: "bg-[var(--color-alert-ci-bg)] text-white hover:brightness-110",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -802,39 +860,42 @@ function getAlerts(session: DashboardSession): Alert[] {
|
|||
if (hasChangesRequested) {
|
||||
alerts.push({
|
||||
key: "changes",
|
||||
type: "changes",
|
||||
icon: "\u21BB",
|
||||
label: "changes requested",
|
||||
className: "",
|
||||
color: "var(--color-alert-changes)",
|
||||
url: pr.url,
|
||||
notified: Boolean(meta["lastPendingReviewDispatchHash"]),
|
||||
actionLabel: "ask to address",
|
||||
actionLabel: "Ask to address",
|
||||
actionMessage: `Please address the requested changes on ${pr.url}`,
|
||||
actionClassName: "bg-[var(--color-alert-changes-bg)] text-white hover:brightness-110",
|
||||
});
|
||||
} else if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) {
|
||||
alerts.push({
|
||||
key: "review",
|
||||
type: "review",
|
||||
icon: (
|
||||
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
),
|
||||
label: "needs review",
|
||||
className: "",
|
||||
color: "var(--color-alert-review)",
|
||||
url: pr.url,
|
||||
actionLabel: "ask to post",
|
||||
actionLabel: "Ask to post",
|
||||
actionMessage: `Post ${pr.url} on slack asking for a review.`,
|
||||
actionClassName: "bg-[var(--color-alert-review-bg)] text-white hover:brightness-110",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasConflicts) {
|
||||
alerts.push({
|
||||
key: "conflict",
|
||||
type: "conflict",
|
||||
icon: "\u26A0",
|
||||
label: "merge conflict",
|
||||
className: "",
|
||||
color: "var(--color-alert-conflict)",
|
||||
url: pr.url,
|
||||
notified: meta["lastMergeConflictDispatched"] === "true",
|
||||
actionLabel: "ask to fix",
|
||||
actionLabel: "Ask to fix",
|
||||
actionMessage: `Please resolve the merge conflicts on ${pr.url} by rebasing on the base branch`,
|
||||
actionClassName: "bg-[var(--color-alert-conflict-bg)] text-white hover:brightness-110",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -842,15 +903,13 @@ function getAlerts(session: DashboardSession): Alert[] {
|
|||
const firstUrl = pr.unresolvedComments[0]?.url ?? pr.url + "/files";
|
||||
alerts.push({
|
||||
key: "comments",
|
||||
type: "comment",
|
||||
icon: "\uD83D\uDCAC",
|
||||
label: "unresolved comments",
|
||||
count: pr.unresolvedThreads,
|
||||
className: "",
|
||||
color: "var(--color-alert-comment)",
|
||||
borderColor: "var(--color-alert-comment)",
|
||||
url: firstUrl,
|
||||
actionLabel: "ask to resolve",
|
||||
actionLabel: "Ask to resolve",
|
||||
actionMessage: `Please address all unresolved review comments on ${pr.url}`,
|
||||
actionClassName: "bg-[var(--color-alert-comment-bg)] text-white hover:brightness-110",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,27 +2,111 @@
|
|||
|
||||
interface EmptyStateProps {
|
||||
message?: string;
|
||||
orchestratorHref?: string | null;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
message,
|
||||
}: EmptyStateProps) {
|
||||
const KANBAN_GHOST_COLUMNS = ["Working", "Pending", "Review", "Respond", "Merge"] as const;
|
||||
|
||||
export function EmptyState({ message, orchestratorHref }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
{/* Terminal icon */}
|
||||
<svg
|
||||
className="mb-4 h-8 w-8 text-[var(--color-border-strong)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M6 9l4 3-4 3M13 15h5" />
|
||||
</svg>
|
||||
<p className="text-[13px] text-[var(--color-text-muted)]">
|
||||
{message ?? "No active sessions"}
|
||||
</p>
|
||||
<div className="board-wrapper">
|
||||
<div className="kanban-ghost" aria-hidden="true">
|
||||
{KANBAN_GHOST_COLUMNS.map((label) => (
|
||||
<div key={label} className="kanban-ghost__col">
|
||||
<div className="kanban-ghost__head">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="board-center">
|
||||
<div className="empty-state" role="status">
|
||||
<div className="empty-state__icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="5.5"
|
||||
r="2.5"
|
||||
fill="rgba(249,115,22,0.18)"
|
||||
stroke="#f97316"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<circle
|
||||
cx="5.5"
|
||||
cy="17"
|
||||
r="2.5"
|
||||
fill="var(--color-bg-subtle)"
|
||||
stroke="var(--color-border-strong)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<circle
|
||||
cx="18.5"
|
||||
cy="17"
|
||||
r="2.5"
|
||||
fill="var(--color-bg-subtle)"
|
||||
stroke="var(--color-border-strong)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="8"
|
||||
x2="6.7"
|
||||
y2="14.8"
|
||||
stroke="rgba(249,115,22,0.22)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2.5 2"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="8"
|
||||
x2="17.3"
|
||||
y2="14.8"
|
||||
stroke="rgba(249,115,22,0.22)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2.5 2"
|
||||
/>
|
||||
<line
|
||||
x1="7.8"
|
||||
y1="17"
|
||||
x2="16.2"
|
||||
y2="17"
|
||||
stroke="var(--color-border-subtle)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2.5 2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{message ? (
|
||||
<p className="empty-state__text">{message}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="empty-state__headline">Ready to orchestrate</p>
|
||||
<p className="empty-state__hint">
|
||||
Open the main orchestrator to start a session and fan out parallel agents across your codebase.
|
||||
</p>
|
||||
{orchestratorHref ? (
|
||||
<a href={orchestratorHref} className="empty-state__cta">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
|
||||
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
|
||||
<circle cx="6" cy="17" r="2" />
|
||||
<circle cx="12" cy="17" r="2" />
|
||||
<circle cx="18" cy="17" r="2" />
|
||||
</svg>
|
||||
Open Orchestrator
|
||||
</a>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,20 +3,28 @@
|
|||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className, label }: ThemeToggleProps) {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!mounted) return <div className="h-9 w-9" />;
|
||||
if (!mounted) return <div className={className ?? "h-9 w-9"} />;
|
||||
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
className="flex h-9 w-9 items-center justify-center border border-[var(--color-border-strong)] bg-[var(--color-bg-elevated)] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-bg-elevated-hover)]"
|
||||
className={
|
||||
className ??
|
||||
"flex h-9 w-9 items-center justify-center border border-[var(--color-border-strong)] bg-[var(--color-bg-elevated)] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-bg-elevated-hover)]"
|
||||
}
|
||||
aria-label={`Switch to ${isDark ? "light" : "dark"} mode`}
|
||||
title={`Switch to ${isDark ? "light" : "dark"} mode`}
|
||||
>
|
||||
|
|
@ -30,6 +38,7 @@ export function ThemeToggle() {
|
|||
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
|
||||
</svg>
|
||||
)}
|
||||
{label ? <span>{label}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ beforeEach(() => {
|
|||
describe("Dashboard empty state", () => {
|
||||
it("shows empty state when there are no sessions (single-project view)", () => {
|
||||
render(<Dashboard initialSessions={[]} />);
|
||||
expect(screen.getByText(/No active sessions/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Ready to orchestrate/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Open the main orchestrator to start a session and fan out parallel agents across your codebase/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show empty state when sessions exist", () => {
|
||||
|
|
@ -53,6 +56,35 @@ describe("Dashboard empty state", () => {
|
|||
]}
|
||||
/>,
|
||||
);
|
||||
expect(queryByText(/No active sessions/i)).not.toBeInTheDocument();
|
||||
expect(queryByText(/Ready to orchestrate/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when only done sessions exist", () => {
|
||||
render(
|
||||
<Dashboard
|
||||
initialSessions={[
|
||||
{
|
||||
id: "s-done",
|
||||
projectId: "proj",
|
||||
status: "killed",
|
||||
activity: "exited",
|
||||
branch: "feat/done",
|
||||
issueId: null,
|
||||
issueUrl: null,
|
||||
issueLabel: null,
|
||||
issueTitle: null,
|
||||
summary: "Finished",
|
||||
summaryIsFallback: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Ready to orchestrate/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Done / Terminated")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ describe("Dashboard mobile layout", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("caps mobile sections to five rows until view-all is tapped", () => {
|
||||
it("shows all sessions in the mobile feed without row caps", () => {
|
||||
const sessions = Array.from({ length: 6 }, (_, index) =>
|
||||
makeSession({
|
||||
id: `needs-input-${index + 1}`,
|
||||
|
|
@ -65,14 +65,10 @@ describe("Dashboard mobile layout", () => {
|
|||
|
||||
expect(screen.getByText("Need approval 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Need approval 5")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Need approval 6")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /view all 6/i }));
|
||||
|
||||
expect(screen.getByText("Need approval 6")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a preview sheet from a mobile row and keeps prompting out of the dashboard", async () => {
|
||||
it("opens a preview sheet from a mobile feed card", async () => {
|
||||
const session = makeSession({
|
||||
id: "respond-1",
|
||||
status: "needs_input",
|
||||
|
|
@ -84,24 +80,16 @@ describe("Dashboard mobile layout", () => {
|
|||
|
||||
render(<Dashboard initialSessions={[session]} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: /go to mobile density/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/sessions/respond-1",
|
||||
);
|
||||
const feedCard = screen.getByRole("button", { name: /respond-1/i });
|
||||
expect(feedCard).toBeInTheDocument();
|
||||
expect(screen.getByText("feat/mobile-density")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open mobile density/i }));
|
||||
fireEvent.click(feedCard);
|
||||
});
|
||||
|
||||
expect(screen.getByRole("link", { name: "Open session" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Terminate" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Mobile Density").length).toBeGreaterThan(1);
|
||||
expect(screen.getAllByText("respond").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("needs input").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("waiting input")).toBeInTheDocument();
|
||||
expect(screen.getByText("feat/mobile-density")).toBeInTheDocument();
|
||||
expect(screen.getByText("#557")).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("Type a reply...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the mobile preview sheet in sync with live session updates", async () => {
|
||||
|
|
@ -117,11 +105,10 @@ describe("Dashboard mobile layout", () => {
|
|||
const { rerender } = render(<Dashboard initialSessions={[session]} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /open mobile density/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /respond-1/i }));
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "Terminate" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText("needs input").length).toBeGreaterThan(0);
|
||||
|
||||
rerender(
|
||||
<Dashboard
|
||||
|
|
@ -239,7 +226,7 @@ describe("Dashboard mobile layout", () => {
|
|||
expect(screen.queryByText("Need approval to proceed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a stable empty state when an expanded mobile section has no sessions", () => {
|
||||
it("shows empty state when a filtered feed has no matching sessions", () => {
|
||||
render(
|
||||
<Dashboard
|
||||
initialSessions={[
|
||||
|
|
@ -256,10 +243,140 @@ describe("Dashboard mobile layout", () => {
|
|||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ready" }));
|
||||
|
||||
expect(screen.getByText("No sessions")).toBeInTheDocument();
|
||||
expect(screen.getByText("No sessions match this filter.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves a deliberate all-collapsed state across session updates", () => {
|
||||
it("shows CI and review pills for enriched PRs in the mobile feed", () => {
|
||||
render(
|
||||
<Dashboard
|
||||
initialSessions={[
|
||||
makeSession({
|
||||
id: "merge-7",
|
||||
status: "approved",
|
||||
activity: "idle",
|
||||
summary: "Ship dashboard polish",
|
||||
branch: "feat/dashboard-polish",
|
||||
pr: makePR({
|
||||
number: 207,
|
||||
additions: 24,
|
||||
deletions: 7,
|
||||
ciStatus: "failing",
|
||||
reviewDecision: "changes_requested",
|
||||
}),
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("feat/dashboard-polish")).toBeInTheDocument();
|
||||
expect(screen.getByText("#207")).toBeInTheDocument();
|
||||
expect(screen.getByText("CI failed")).toBeInTheDocument();
|
||||
expect(screen.getByText("changes requested")).toBeInTheDocument();
|
||||
expect(screen.getByText("+24")).toBeInTheDocument();
|
||||
expect(screen.getByText("-7")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows and dismisses the rate limit banner", () => {
|
||||
render(
|
||||
<Dashboard
|
||||
initialSessions={[
|
||||
makeSession({
|
||||
id: "review-2",
|
||||
status: "reviewing",
|
||||
activity: "idle",
|
||||
pr: makePR({
|
||||
number: 208,
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["API rate limited or unavailable"],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/GitHub API rate limited/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
expect(screen.queryByText(/GitHub API rate limited/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the done bar and restores completed sessions", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-11T11:07:00.000Z"));
|
||||
|
||||
render(
|
||||
<Dashboard
|
||||
initialSessions={[
|
||||
makeSession({
|
||||
id: "done-1",
|
||||
status: "terminated",
|
||||
activity: "exited",
|
||||
summaryIsFallback: true,
|
||||
issueTitle: "Restore completed agent",
|
||||
branch: null,
|
||||
lastActivityAt: "2026-04-11T09:07:00.000Z",
|
||||
pr: makePR({ number: 209, state: "merged", title: "Wrapped up work" }),
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Done \/ Terminated/i }));
|
||||
|
||||
expect(screen.getByText("Restore completed agent")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "#209" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/acme/app/pull/100",
|
||||
);
|
||||
expect(screen.getByText("merged")).toBeInTheDocument();
|
||||
expect(screen.getByText("2h ago")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Restore" }));
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/sessions/done-1/restore", {
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("confirms termination from the mobile preview sheet", async () => {
|
||||
render(
|
||||
<Dashboard
|
||||
initialSessions={[
|
||||
makeSession({
|
||||
id: "respond-terminate",
|
||||
status: "needs_input",
|
||||
activity: "waiting_input",
|
||||
summary: "Need a kill confirmation path",
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /respond-terminate/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Terminate" }));
|
||||
});
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Terminate" }).at(-1)!);
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/sessions/respond-terminate/kill", {
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves feed cards across session updates", () => {
|
||||
const { rerender } = render(
|
||||
<Dashboard
|
||||
initialSessions={[
|
||||
|
|
@ -281,11 +398,8 @@ describe("Dashboard mobile layout", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const respondAccordion = screen.getByRole("button", { name: /respond 1/i });
|
||||
expect(respondAccordion).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
fireEvent.click(respondAccordion);
|
||||
expect(respondAccordion).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.getByText("Need approval to proceed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Implement dashboard filters")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<Dashboard
|
||||
|
|
@ -310,13 +424,7 @@ describe("Dashboard mobile layout", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /respond 1/i })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /working 1/i })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
expect(screen.getByText("Need approval to proceed")).toBeInTheDocument();
|
||||
expect(screen.getByText("Implement dashboard filters")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ describe("Dashboard project overview cards", () => {
|
|||
expect(screen.getAllByRole("button", { name: "Spawn Orchestrator" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows a desktop PRs link for project-scoped dashboards", () => {
|
||||
it("omits the desktop PRs link for project-scoped dashboards in the current layout", () => {
|
||||
render(
|
||||
<Dashboard
|
||||
initialSessions={[makeSession({ projectId: "my-app" })]}
|
||||
|
|
@ -67,13 +67,10 @@ describe("Dashboard project overview cards", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "PRs" })).toHaveAttribute(
|
||||
"href",
|
||||
"/prs?project=my-app",
|
||||
);
|
||||
expect(screen.queryByRole("link", { name: "PRs" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a desktop PRs link for all-projects dashboards", () => {
|
||||
it("omits the desktop PRs link for all-projects dashboards", () => {
|
||||
render(
|
||||
<Dashboard
|
||||
initialSessions={[makeSession({ projectId: "my-app" })]}
|
||||
|
|
@ -84,7 +81,7 @@ describe("Dashboard project overview cards", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "PRs" })).toHaveAttribute("href", "/prs?project=all");
|
||||
expect(screen.queryByRole("link", { name: "PRs" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates the card after spawning an orchestrator", async () => {
|
||||
|
|
|
|||
|
|
@ -137,4 +137,21 @@ describe("DirectTerminal render", () => {
|
|||
expect(screen.getByText("ao-orchestrator")).toHaveStyle({ color: "var(--color-accent)" });
|
||||
expect(screen.getByText("XDA")).toHaveStyle({ color: "var(--color-accent)" });
|
||||
});
|
||||
|
||||
it("keeps restart and fullscreen actions available in chromeless mode", async () => {
|
||||
render(
|
||||
<DirectTerminal
|
||||
sessionId="ao-opencode"
|
||||
chromeless
|
||||
isOpenCodeSession
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Restart OpenCode session" })).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "fullscreen" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("XDA")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,9 +15,25 @@ describe("PRCard diff coverage", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: /#635 hydrate pr details later/i })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: /#635/i })).toBeTruthy();
|
||||
expect(screen.queryByText("approved")).toBeNull();
|
||||
expect(screen.queryByText("CI passing")).toBeNull();
|
||||
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThanOrEqual(3);
|
||||
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("does not show review-needed text for merged PRs", () => {
|
||||
render(
|
||||
<PRCard
|
||||
pr={makePR({
|
||||
number: 636,
|
||||
title: "Already merged",
|
||||
state: "merged",
|
||||
reviewDecision: "pending",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("merged")).toBeInTheDocument();
|
||||
expect(screen.queryByText("needs review")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,78 +1,215 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ProjectSidebar } from "@/components/ProjectSidebar";
|
||||
import { makeSession } from "@/__tests__/helpers";
|
||||
|
||||
const mockPush = vi.fn();
|
||||
const mockPathname = "/";
|
||||
let mockPathname = "/";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: () => mockPathname,
|
||||
}));
|
||||
|
||||
vi.mock("next-themes", () => ({
|
||||
useTheme: () => ({
|
||||
resolvedTheme: "light",
|
||||
setTheme: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ProjectSidebar", () => {
|
||||
const projects = [
|
||||
{ id: "project-1", name: "Project One" },
|
||||
{ id: "project-2", name: "Project Two" },
|
||||
{ id: "project-3", name: "Project Three" },
|
||||
{ id: "project-1", name: "Project One", sessionPrefix: "project-1" },
|
||||
{ id: "project-2", name: "Project Two", sessionPrefix: "project-2" },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear();
|
||||
});
|
||||
|
||||
it("renders nothing when there is only one project", () => {
|
||||
const { container } = render(
|
||||
<ProjectSidebar projects={[projects[0]]} sessions={[]} activeProjectId="project-1" activeSessionId={undefined} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
mockPush.mockReset();
|
||||
mockPathname = "/";
|
||||
});
|
||||
|
||||
it("renders nothing when there are no projects", () => {
|
||||
const { container } = render(<ProjectSidebar projects={[]} sessions={[]} activeProjectId={undefined} activeSessionId={undefined} />);
|
||||
const { container } = render(
|
||||
<ProjectSidebar
|
||||
projects={[]}
|
||||
sessions={[]}
|
||||
activeProjectId={undefined}
|
||||
activeSessionId={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders sidebar with all projects when there are multiple", () => {
|
||||
render(<ProjectSidebar projects={projects} sessions={[]} activeProjectId="project-1" activeSessionId={undefined} />);
|
||||
it("renders the compact sidebar header and project rows", () => {
|
||||
render(
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={[]}
|
||||
activeProjectId="project-1"
|
||||
activeSessionId={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Projects")).toBeInTheDocument();
|
||||
expect(screen.getByText("All Projects")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project One")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project Two")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project Three")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Project One/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Project Two/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /new project/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights active project", () => {
|
||||
render(<ProjectSidebar projects={projects} sessions={[]} activeProjectId="project-2" activeSessionId={undefined} />);
|
||||
const projectTwoButton = screen.getByRole("button", { name: "Project Two" });
|
||||
expect(projectTwoButton.className).toContain("accent");
|
||||
it("marks the active project row as the current page", () => {
|
||||
render(
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={[]}
|
||||
activeProjectId="project-2"
|
||||
activeSessionId={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Project Two/ })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Project One/ })).not.toHaveAttribute(
|
||||
"aria-current",
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights 'All Projects' when no project is active", () => {
|
||||
render(<ProjectSidebar projects={projects} sessions={[]} activeProjectId={undefined} activeSessionId={undefined} />);
|
||||
const allProjectsButton = screen.getByRole("button", { name: "All Projects" });
|
||||
expect(allProjectsButton.className).toContain("accent");
|
||||
});
|
||||
it("navigates to the project query param when clicking a project", () => {
|
||||
render(
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={[]}
|
||||
activeProjectId="project-1"
|
||||
activeSessionId={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Two/ }));
|
||||
|
||||
it("navigates to project query param when clicking a project", () => {
|
||||
render(<ProjectSidebar projects={projects} sessions={[]} activeProjectId="project-1" activeSessionId={undefined} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Project Two" }));
|
||||
expect(mockPush).toHaveBeenCalledWith("/?project=project-2");
|
||||
});
|
||||
|
||||
it("navigates to 'all' when clicking 'All Projects'", () => {
|
||||
render(<ProjectSidebar projects={projects} sessions={[]} activeProjectId="project-1" activeSessionId={undefined} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "All Projects" }));
|
||||
expect(mockPush).toHaveBeenCalledWith("/?project=all");
|
||||
it("navigates to the dashboard root from session pages", () => {
|
||||
mockPathname = "/sessions/ao-143";
|
||||
|
||||
render(
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={[]}
|
||||
activeProjectId="project-1"
|
||||
activeSessionId={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Project Two/ }));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/?project=project-2");
|
||||
});
|
||||
|
||||
it("encodes project ID in URL", () => {
|
||||
const projectsWithSpecialChars = [
|
||||
{ id: "my-app", name: "My App" },
|
||||
{ id: "other-project", name: "Other Project" },
|
||||
];
|
||||
render(<ProjectSidebar projects={projectsWithSpecialChars} sessions={[]} activeProjectId="my-app" activeSessionId={undefined} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Other Project" }));
|
||||
expect(mockPush).toHaveBeenCalledWith("/?project=other-project");
|
||||
it("shows non-done worker sessions for the expanded active project", () => {
|
||||
render(
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={[
|
||||
makeSession({
|
||||
id: "worker-1",
|
||||
projectId: "project-1",
|
||||
summary: "Review API changes",
|
||||
branch: null,
|
||||
status: "needs_input",
|
||||
activity: "waiting_input",
|
||||
}),
|
||||
makeSession({
|
||||
id: "worker-2",
|
||||
projectId: "project-1",
|
||||
summary: "Already done",
|
||||
status: "merged",
|
||||
activity: "exited",
|
||||
}),
|
||||
]}
|
||||
activeProjectId="project-1"
|
||||
activeSessionId="worker-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Open Review API changes" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Open feat/test" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates session rows to the selected session detail route", () => {
|
||||
mockPathname = "/sessions/ao-143";
|
||||
|
||||
render(
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={[
|
||||
makeSession({
|
||||
id: "worker-1",
|
||||
projectId: "project-1",
|
||||
summary: "Review API changes",
|
||||
branch: null,
|
||||
status: "needs_input",
|
||||
activity: "waiting_input",
|
||||
}),
|
||||
makeSession({
|
||||
id: "worker-2",
|
||||
projectId: "project-1",
|
||||
summary: "Implement sidebar polish",
|
||||
branch: null,
|
||||
status: "working",
|
||||
activity: "active",
|
||||
}),
|
||||
]}
|
||||
activeProjectId="project-1"
|
||||
activeSessionId="worker-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open Implement sidebar polish" }));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/sessions/worker-2?project=project-1");
|
||||
});
|
||||
|
||||
it("filters out orchestrator sessions from the project tree", () => {
|
||||
render(
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={[
|
||||
makeSession({
|
||||
id: "project-1-orchestrator",
|
||||
projectId: "project-1",
|
||||
summary: "Orchestrator",
|
||||
}),
|
||||
makeSession({
|
||||
id: "worker-1",
|
||||
projectId: "project-1",
|
||||
summary: "Implement sidebar polish",
|
||||
}),
|
||||
]}
|
||||
activeProjectId="project-1"
|
||||
activeSessionId={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Orchestrator")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the collapsed rail when collapsed", () => {
|
||||
const { container } = render(
|
||||
<ProjectSidebar
|
||||
projects={projects}
|
||||
sessions={[]}
|
||||
activeProjectId="project-1"
|
||||
activeSessionId={undefined}
|
||||
collapsed
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".project-sidebar--collapsed")).not.toBeNull();
|
||||
expect(screen.queryByText("Projects")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,22 @@ function mockMobileViewport() {
|
|||
});
|
||||
}
|
||||
|
||||
function mockDesktopViewport() {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("PullRequestsPage", () => {
|
||||
beforeEach(() => {
|
||||
mockMobileViewport();
|
||||
|
|
@ -92,4 +108,44 @@ describe("PullRequestsPage", () => {
|
|||
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute("href", "/?project=all");
|
||||
expect(screen.getByRole("link", { name: "PRs" })).toHaveAttribute("href", "/prs?project=all");
|
||||
});
|
||||
|
||||
it("renders merged and closed desktop sections when the default all filter is active", () => {
|
||||
mockDesktopViewport();
|
||||
|
||||
render(
|
||||
<PullRequestsPage
|
||||
initialSessions={[
|
||||
makeSession({
|
||||
id: "merged-1",
|
||||
projectId: "my-app",
|
||||
status: "merged",
|
||||
pr: makePR({ number: 101, title: "Merged PR", state: "merged" }),
|
||||
}),
|
||||
makeSession({
|
||||
id: "closed-1",
|
||||
projectId: "my-app",
|
||||
status: "killed",
|
||||
pr: makePR({ number: 102, title: "Closed PR", state: "closed" }),
|
||||
}),
|
||||
]}
|
||||
projectId="my-app"
|
||||
projectName="My App"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("Merged")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Closed")).toHaveLength(2);
|
||||
expect(screen.getByRole("link", { name: "#101" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "#102" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Merged PR")).toBeInTheDocument();
|
||||
expect(screen.getByText("Closed PR")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty desktop state when there are no pull requests", () => {
|
||||
mockDesktopViewport();
|
||||
|
||||
render(<PullRequestsPage initialSessions={[]} projectId="my-app" projectName="My App" />);
|
||||
|
||||
expect(screen.getByText("No pull requests yet.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionDetail } from "../SessionDetail";
|
||||
import { makePR, makeSession } from "../../__tests__/helpers";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
|
||||
usePathname: () => "/sessions/worker-desktop",
|
||||
}));
|
||||
|
||||
vi.mock("../DirectTerminal", () => ({
|
||||
DirectTerminal: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="direct-terminal">{sessionId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function mockDesktopViewport() {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: () => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("SessionDetail desktop layout", () => {
|
||||
beforeEach(() => {
|
||||
mockDesktopViewport();
|
||||
window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
window.cancelAnimationFrame = vi.fn();
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(""),
|
||||
} as Response),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders the desktop shell, PR blockers, and unresolved comments", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "worker-desktop",
|
||||
projectId: "my-app",
|
||||
summary: "Desktop session detail",
|
||||
branch: "feat/desktop-detail",
|
||||
pr: makePR({
|
||||
number: 310,
|
||||
title: "Desktop detail coverage",
|
||||
branch: "feat/desktop-detail",
|
||||
additions: 18,
|
||||
deletions: 4,
|
||||
ciStatus: "pending",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "failed" },
|
||||
{ name: "lint", status: "pending" },
|
||||
{ name: "typecheck", status: "queued" },
|
||||
],
|
||||
reviewDecision: "changes_requested",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: false,
|
||||
noConflicts: false,
|
||||
blockers: [],
|
||||
},
|
||||
changedFiles: 3,
|
||||
isDraft: true,
|
||||
unresolvedThreads: 1,
|
||||
unresolvedComments: [
|
||||
{
|
||||
url: "https://github.com/acme/app/pull/310#discussion_r1",
|
||||
path: "packages/web/src/components/SessionDetail.tsx",
|
||||
author: "bugbot",
|
||||
body: "### Tighten the copy\n<!-- DESCRIPTION START -->The empty state text needs to be shorter.<!-- DESCRIPTION END -->",
|
||||
},
|
||||
],
|
||||
}),
|
||||
metadata: {
|
||||
status: "changes_requested",
|
||||
lastMergeConflictDispatched: "true",
|
||||
lastPendingReviewDispatchHash: "review-hash",
|
||||
},
|
||||
})}
|
||||
projectOrchestratorId="my-app-orchestrator"
|
||||
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
|
||||
sidebarSessions={[makeSession({ id: "sidebar-1" })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText("My App").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByRole("link", { name: "Orchestrator" })).toHaveAttribute(
|
||||
"href",
|
||||
"/sessions/my-app-orchestrator",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "feat/desktop-detail" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/acme/app/tree/feat/desktop-detail",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "PR #310" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/acme/app/pull/100",
|
||||
);
|
||||
expect(screen.getByText("3 files")).toBeInTheDocument();
|
||||
expect(screen.getByText("Draft")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Changes requested/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Merge conflicts/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Unresolved Comments/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Tighten the copy")).toBeInTheDocument();
|
||||
expect(screen.getByText("The empty state text needs to be shorter.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Live Terminal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends unresolved comments back to the agent and shows sent state", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "worker-fix",
|
||||
projectId: "my-app",
|
||||
pr: makePR({
|
||||
number: 311,
|
||||
unresolvedThreads: 1,
|
||||
unresolvedComments: [
|
||||
{
|
||||
url: "https://github.com/acme/app/pull/311#discussion_r2",
|
||||
path: "packages/web/src/components/Skeleton.tsx",
|
||||
author: "bugbot",
|
||||
body: "### Improve empty state\n<!-- DESCRIPTION START -->Use a stronger CTA label.<!-- DESCRIPTION END -->",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ask Agent to Fix" }));
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith("/api/sessions/worker-fix/message", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: expect.stringContaining("Improve empty state"),
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /Sent/i })).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "Ask Agent to Fix" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows terminal-ended placeholder for exited desktop sessions", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "worker-ended",
|
||||
projectId: "my-app",
|
||||
status: "terminated",
|
||||
activity: "exited",
|
||||
pr: null,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Terminal session has ended/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("direct-terminal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -54,15 +54,13 @@ describe("SessionDetail mobile navbar", () => {
|
|||
|
||||
const nav = screen.getByRole("navigation", { name: /session navigation/i });
|
||||
expect(nav).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute("href", "/?project=my-app");
|
||||
expect(screen.getByRole("link", { name: "PRs" })).toHaveAttribute("href", "/prs?project=my-app");
|
||||
expect(within(nav).getByRole("link", { name: "Dashboard" })).toHaveAttribute("href", "/?project=my-app");
|
||||
expect(within(nav).getByRole("link", { name: "PRs" })).toHaveAttribute("href", "/prs?project=my-app");
|
||||
expect(screen.getAllByRole("link", { name: "Orchestrator" }).at(-1)).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
expect(screen.getAllByText("Orchestrator session title")).toHaveLength(1);
|
||||
expect(screen.queryByText("agents")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("responding")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("my-app-orchestrator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("routes PRs to the dedicated page from worker session pages", () => {
|
||||
|
|
@ -110,7 +108,7 @@ describe("SessionDetail mobile navbar", () => {
|
|||
expect(within(nav).queryByRole("button", { name: "Orchestrator" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps branch and PR chips in the compact mobile header", () => {
|
||||
it("shows session ID and PR info in the terminal-first layout", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
|
|
@ -124,17 +122,11 @@ describe("SessionDetail mobile navbar", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Compact header polish")).toBeInTheDocument();
|
||||
expect(screen.getByText("feat/compact-header")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "PR #77" })).toHaveClass(
|
||||
"session-detail-link-pill--link",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "feat/compact-header" })).toHaveClass(
|
||||
"session-detail-link-pill--link",
|
||||
);
|
||||
expect(screen.getByText("worker-2")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /PR #77/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prefers issue title over changing summary text", () => {
|
||||
it("shows session ID in floating header for terminal-first layout", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
|
|
@ -148,11 +140,11 @@ describe("SessionDetail mobile navbar", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Fix stable session titles")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Responding to latest review comment")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("worker-stable-title")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Back to dashboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves CI and unresolved review comment detail on mobile session pages", () => {
|
||||
it("shows PR bottom sheet with CI and review summary on mobile", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
|
|
@ -163,44 +155,20 @@ describe("SessionDetail mobile navbar", () => {
|
|||
number: 88,
|
||||
title: "Keep PR detail intact",
|
||||
ciStatus: "failing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "failed", url: "https://ci.example/build" },
|
||||
{ name: "lint", status: "passed", url: "https://ci.example/lint" },
|
||||
],
|
||||
reviewDecision: "changes_requested",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["CI failing", "Changes requested"],
|
||||
},
|
||||
unresolvedThreads: 2,
|
||||
unresolvedComments: [
|
||||
{
|
||||
url: "https://github.com/acme/app/pull/88#discussion_r1",
|
||||
path: "src/app.ts",
|
||||
author: "bugbot",
|
||||
body: "### Fix null handling\n<!-- DESCRIPTION START -->Handle missing data safely<!-- DESCRIPTION END -->",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})}
|
||||
projectOrchestratorId="my-app-orchestrator"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/CI failing/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Changes requested/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 unresolved comments/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Unresolved Comments")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fix null handling")).toBeInTheDocument();
|
||||
expect(screen.getByText("build")).toBeInTheDocument();
|
||||
expect(screen.getByText("lint")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ask Agent to Fix" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /PR #88/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/fail/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/changes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the merged badge styling for merged PR sessions", () => {
|
||||
it("shows PR link in bottom sheet for merged PR sessions", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
|
|
@ -217,11 +185,7 @@ describe("SessionDetail mobile navbar", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const mergedBadge = screen.getByText("Merged");
|
||||
expect(mergedBadge).toBeInTheDocument();
|
||||
expect(mergedBadge).toHaveStyle({
|
||||
color: "var(--color-text-secondary)",
|
||||
background: "var(--color-chip-bg)",
|
||||
});
|
||||
expect(screen.getByRole("link", { name: /PR #89/i })).toBeInTheDocument();
|
||||
expect(screen.getByText("worker-merged")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ export function useSessionEvents(
|
|||
project?: string,
|
||||
muxSessions?: Array<{ id: string; status: string; activity: string | null; attentionLevel: string; lastActivityAt: string }>,
|
||||
initialAttentionLevels?: SSEAttentionMap,
|
||||
disabled = false,
|
||||
): State {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
sessions: initialSessions,
|
||||
|
|
@ -205,7 +206,7 @@ export function useSessionEvents(
|
|||
|
||||
// Mux-based session updates (replaces SSE when available)
|
||||
useEffect(() => {
|
||||
if (!muxSessions) return;
|
||||
if (disabled || !muxSessions) return;
|
||||
// Note: empty array is intentional — it means all sessions were removed and we
|
||||
// must still run the membership-key comparison to trigger scheduleRefresh().
|
||||
|
||||
|
|
@ -238,9 +239,11 @@ export function useSessionEvents(
|
|||
activeRefreshControllerRef.current?.abort();
|
||||
activeRefreshControllerRef.current = null;
|
||||
};
|
||||
}, [muxSessions, scheduleRefresh]);
|
||||
}, [disabled, muxSessions, scheduleRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
|
||||
// Skip SSE if mux sessions are available
|
||||
if (muxActive) {
|
||||
dispatch({ type: "setConnection", status: "connected" });
|
||||
|
|
@ -340,7 +343,7 @@ export function useSessionEvents(
|
|||
clearDisconnectedTimer();
|
||||
es.close();
|
||||
};
|
||||
}, [project, muxActive, scheduleRefresh]);
|
||||
}, [disabled, project, muxActive, scheduleRefresh]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,14 @@ describe("getAttentionLevel", () => {
|
|||
});
|
||||
expect(getAttentionLevel(session)).toBe("done");
|
||||
});
|
||||
|
||||
it("should ignore metadata attention overrides for terminal sessions", () => {
|
||||
const session = createSession({
|
||||
status: "terminated",
|
||||
metadata: { attentionLevel: "working" },
|
||||
});
|
||||
expect(getAttentionLevel(session)).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("merge state", () => {
|
||||
|
|
@ -174,6 +182,14 @@ describe("getAttentionLevel", () => {
|
|||
});
|
||||
expect(getAttentionLevel(session)).toBe("merge");
|
||||
});
|
||||
|
||||
it("should ignore metadata attention overrides when merge criteria are met", () => {
|
||||
const session = createSession({
|
||||
status: "mergeable",
|
||||
metadata: { attentionLevel: "working" },
|
||||
});
|
||||
expect(getAttentionLevel(session)).toBe("merge");
|
||||
});
|
||||
});
|
||||
|
||||
describe("respond state", () => {
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export interface DashboardPR {
|
|||
state: "open" | "merged" | "closed";
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changedFiles?: number;
|
||||
ciStatus: CIStatus;
|
||||
ciChecks: DashboardCICheck[];
|
||||
reviewDecision: ReviewDecision;
|
||||
|
|
|
|||
Loading…
Reference in New Issue