diff --git a/packages/web/package.json b/packages/web/package.json index 1a46817f1..1f239a963 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -38,6 +38,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "next": "^15.1.0", + "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", "ws": "^8.19.0", diff --git a/packages/web/src/__tests__/components.test.tsx b/packages/web/src/__tests__/components.test.tsx index c2678b8d5..67f323801 100644 --- a/packages/web/src/__tests__/components.test.tsx +++ b/packages/web/src/__tests__/components.test.tsx @@ -247,7 +247,7 @@ describe("SessionCard", () => { }); const session = makeSession({ status: "mergeable", activity: "idle", pr }); render(); - expect(screen.getByText("Merge PR #42")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /merge/i })).toBeInTheDocument(); }); it("calls onMerge when merge button is clicked", () => { @@ -265,7 +265,7 @@ describe("SessionCard", () => { }); const session = makeSession({ status: "mergeable", activity: "idle", pr }); render(); - fireEvent.click(screen.getByText("Merge PR #42")); + fireEvent.click(screen.getByRole("button", { name: /merge/i })); expect(onMerge).toHaveBeenCalledWith(42); }); @@ -396,7 +396,7 @@ describe("SessionCard", () => { expect(screen.getByText("ask to fix")).toBeInTheDocument(); }); - it("hides action buttons when agent is active", () => { + it("shows action buttons even when agent is active", () => { const pr = makePR({ state: "open", ciStatus: "failing", @@ -412,24 +412,19 @@ describe("SessionCard", () => { }); const session = makeSession({ activity: "active", pr }); render(); - expect(screen.queryByText("ask to fix")).not.toBeInTheDocument(); + expect(screen.getByText("ask to fix")).toBeInTheDocument(); }); - it("expands detail panel on click", () => { + it("shows issue details in the compact card footer", () => { const session = makeSession({ id: "test-1", issueId: "INT-100", pr: null }); - const { container } = render(); - expect(screen.queryByText("INT-100")).not.toBeInTheDocument(); - // Click the card (not a button/link) - fireEvent.click(container.firstElementChild!); - expect(screen.getByText("INT-100")).toBeInTheDocument(); - expect(screen.getByText("No PR associated with this session.")).toBeInTheDocument(); + render(); + expect(screen.getAllByText("INT-100")).toHaveLength(2); }); - it("shows terminate button in expanded view", () => { + it("shows icon-only terminate button in the footer", () => { const session = makeSession({ pr: null }); - const { container } = render(); - fireEvent.click(container.firstElementChild!); - expect(screen.getByText("terminate")).toBeInTheDocument(); + render(); + expect(screen.getByRole("button", { name: /terminate session/i })).toBeInTheDocument(); }); }); @@ -444,9 +439,9 @@ describe("AttentionZone", () => { expect(screen.getByText("2")).toBeInTheDocument(); }); - it("renders nothing when sessions array is empty", () => { - const { container } = render(); - expect(container.firstElementChild).toBeNull(); + it("renders empty state when sessions array is empty", () => { + render(); + expect(screen.getByText("No sessions")).toBeInTheDocument(); }); it("shows session cards when not collapsed", () => { @@ -463,27 +458,11 @@ describe("AttentionZone", () => { expect(screen.getByText("Working")).toBeInTheDocument(); }); - it("done zone is collapsed by default", () => { + it("done zone always shows sessions (kanban columns are always expanded)", () => { const sessions = [makeSession({ id: "s1" })]; render(); - // done is defaultCollapsed: true, so session id should not be visible - expect(screen.queryByText("s1")).not.toBeInTheDocument(); expect(screen.getByText("Done")).toBeInTheDocument(); - }); - - it("toggles collapsed state on click", () => { - const sessions = [makeSession({ id: "s1" })]; - render(); - // done starts collapsed - expect(screen.queryByText("s1")).not.toBeInTheDocument(); - - // Click the zone header to expand - fireEvent.click(screen.getByText("Done")); expect(screen.getByText("s1")).toBeInTheDocument(); - - // Click again to collapse - fireEvent.click(screen.getByText("Done")); - expect(screen.queryByText("s1")).not.toBeInTheDocument(); }); it("passes callbacks to SessionCards", () => { diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 211c3f38f..b592cdb0a 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -1,83 +1,418 @@ +/** + * Design System Tokens — Agent Orchestrator Dashboard + * ===================================================== + * All tokens are defined in @theme using Tailwind v4 CSS custom properties. + * Reference tokens in components via: bg-[var(--color-bg-surface)], etc. + * + * Token categories: + * --color-bg-* Surface / background colors + * --color-border-* Border colors + * --color-text-* Text colors + * --color-accent-* Interactive and semantic accent colors + * --color-status-* Status indicator colors + * --font-* Font family stacks (in @theme — generates font-family utilities) + * --radius-* Border-radius scale (in @theme — generates rounded-* utilities) + * --transition-* Easing / duration tokens (in @theme) + * + * Tokens in :root (not @theme, to avoid Tailwind utility namespace collisions): + * --font-size-* Font-size scale (xs → xl); avoids --font-* font-family collision + * --box-shadow-* Box-shadow levels (sm → xl); avoids --shadow-* utility collision + * --space-* Spacing reference tokens; avoids potential --space-* utility collision + * --z-* Z-index scale; Tailwind v4 z-index namespace is --z-index-*, not --z-* + * --gradient-* Named gradient surfaces for cards and buttons + */ + @import "tailwindcss"; -@theme { - /* ── Base surfaces ────────────────────────────────────────────────── */ - --color-bg-base: #0d1117; - --color-bg-surface: rgba(22, 27, 34, 0.8); - --color-bg-elevated: rgba(28, 33, 40, 0.9); - --color-bg-subtle: rgba(33, 38, 45, 0.7); +/* ── Light mode (default) ─────────────────────────────────────────── */ +:root { + /* Base surfaces — Linear-standard light palette */ + --color-bg-base: #ffffff; + --color-bg-surface: #ffffff; + --color-bg-elevated: #ffffff; + --color-bg-elevated-hover: #f7f7f8; + --color-bg-subtle: #f2f2f2; /* Backward-compat aliases */ - --color-bg-primary: var(--color-bg-base); + --color-bg-primary: var(--color-bg-base); --color-bg-secondary: var(--color-bg-surface); - --color-bg-tertiary: var(--color-bg-elevated); + --color-bg-tertiary: var(--color-bg-elevated); - /* ── Borders ─────────────────────────────────────────────────────── */ - --color-border-subtle: rgba(48, 54, 61, 0.6); - --color-border-default: rgba(48, 54, 61, 1); - --color-border-strong: rgba(72, 79, 88, 1); + /* Borders */ + --color-border-subtle: #e8e8ec; + --color-border-default: #d9d9de; + --color-border-strong: #c1c1c6; /* Backward-compat aliases */ - --color-border-muted: var(--color-border-subtle); + --color-border-muted: var(--color-border-subtle); --color-border-emphasis: var(--color-border-strong); - /* ── Text ─────────────────────────────────────────────────────────── */ - --color-text-primary: #e6edf3; - --color-text-secondary: #7d8590; - --color-text-tertiary: #484f58; - --color-text-muted: #484f58; - --color-text-inverse: #0d1117; + /* Text */ + --color-text-primary: #1b1b1f; + --color-text-secondary: #5e5e66; + --color-text-tertiary: #8b8b93; + --color-text-muted: #8b8b93; + --color-text-inverse: #ffffff; - /* ── Interactive accent ──────────────────────────────────────────── */ - --color-accent: #58a6ff; - --color-accent-hover: #79b8ff; - --color-accent-subtle: rgba(88, 166, 255, 0.12); + /* Interactive accent — Linear indigo */ + --color-accent: #5e6ad2; + --color-accent-hover: #4850b8; + --color-accent-subtle: rgba(94, 106, 210, 0.08); - /* ── Status / semantic colors ────────────────────────────────────── */ - --color-status-working: #58a6ff; - --color-status-ready: #3fb950; - --color-status-attention: #d29922; - --color-status-idle: #484f58; - --color-status-done: #30363d; - --color-status-error: #f85149; + /* Status / semantic colors */ + --color-status-working: #5e6ad2; + --color-status-ready: #1a7f37; + --color-status-attention: #9a6700; + --color-status-idle: #8b949e; + --color-status-done: #d0d7de; + --color-status-error: #cf222e; /* Semantic aliases */ - --color-accent-blue: #58a6ff; - --color-accent-green: #3fb950; - --color-accent-yellow: #d29922; - --color-accent-orange: #d18616; - --color-accent-red: #f85149; - --color-accent-violet: #a371f7; - --color-accent-purple: #bc8cff; + --color-accent-blue: #5e6ad2; + --color-accent-green: #1a7f37; + --color-accent-yellow: #9a6700; + --color-accent-orange: #bc4c00; + --color-accent-red: #cf222e; + --color-accent-violet: #8250df; + --color-accent-purple: #8250df; + /* Theme-adaptive tints (bg tints behind status pills) */ + --color-tint-blue: rgba(94, 106, 210, 0.08); + --color-tint-green: rgba(26, 127, 55, 0.08); + --color-tint-yellow: rgba(154, 103, 0, 0.08); + --color-tint-red: rgba(207, 34, 46, 0.08); + --color-tint-violet: rgba(130, 80, 223, 0.08); + --color-tint-orange: rgba(188, 76, 0, 0.08); + --color-tint-neutral: rgba(0, 0, 0, 0.04); + + /* Chip/badge background */ + --color-chip-bg: #f2f2f2; + + /* Hover overlay */ + --color-hover-overlay: #f7f7f8; + + /* Scrollbar */ + --color-scrollbar: rgba(0, 0, 0, 0.08); + --color-scrollbar-hover: rgba(0, 0, 0, 0.15); + --color-scrollbar-active: rgba(0, 0, 0, 0.25); + + /* Body radial gradient tints — none in light mode */ + --color-body-gradient-blue: transparent; + --color-body-gradient-violet: transparent; + + /* Nav */ + --color-nav-bg: #ffffff; + + /* Card surfaces — clean white on light gray */ + --card-bg: #ffffff; + --card-merge-bg: #f6fbf8; + --card-expanded-bg: #f7f7f8; + --card-shadow: none; + --card-shadow-hover: 0 2px 6px rgba(0, 0, 0, 0.08); + --card-inset: none; + --card-border: #d5d7de; + + /* Done card surfaces */ + --card-done-bg: #fafafa; + --card-done-border: #e8e8ec; + + --done-pill-exited-bg: #f2f2f2; + --done-pill-exited-color: #8b8b93; + --done-pill-merged-bg: rgba(26, 127, 55, 0.08); + --done-pill-merged-color: #1a7f37; + --done-pill-killed-bg: rgba(207, 34, 46, 0.06); + --done-pill-killed-color: #cf222e; + --done-meta-chip-bg: #f2f2f2; + --done-meta-chip-border: #e8e8ec; + --done-section-border: #ececf0; + --done-title-color: #5e5e66; + --done-restore-bg: rgba(9, 105, 218, 0.06); + --done-restore-border: rgba(9, 105, 218, 0.2); + --done-restore-hover-bg: rgba(9, 105, 218, 0.12); + + /* Detail card */ + --detail-card-bg: #ffffff; + --detail-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); + + /* Orchestrator button */ + --btn-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + --btn-shadow-hover: 0 2px 4px rgba(0, 0, 0, 0.1); + --btn-inset: none; + + /* Kanban column */ + --color-column-bg: #f4f5f7; + --color-column-header: transparent; + + /* Alert colors */ + --color-alert-ci: #6366f1; + --color-alert-ci-bg: #4f46e5; + --color-alert-ci-unknown: #9a6700; + --color-alert-review: #1a7f37; + --color-alert-review-bg: #1a7f37; + --color-alert-changes: #8250df; + --color-alert-changes-bg: #7c3aed; + --color-alert-conflict: #9a6700; + --color-alert-conflict-bg: #92400e; + --color-alert-comment: #bc4c00; + --color-alert-comment-bg: #c2410c; + + /* Activity pulse glow */ + --color-activity-pulse: rgba(94, 106, 210, 0.45); + --color-activity-pulse-end: rgba(94, 106, 210, 0); +} + +/* ── Dark mode ────────────────────────────────────────────────────── */ +.dark { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /* Base surfaces — graphite dashboard */ + --color-bg-base: #0a0d12; + --color-bg-surface: #11161d; + --color-bg-elevated: #171d26; + --color-bg-elevated-hover: #1c2430; + --color-bg-subtle: rgba(177, 206, 255, 0.05); + + /* Borders */ + --color-border-subtle: rgba(160, 190, 255, 0.08); + --color-border-default: rgba(160, 190, 255, 0.14); + --color-border-strong: rgba(185, 214, 255, 0.24); + + /* Text */ + --color-text-primary: #eef3ff; + --color-text-secondary: #a5afc4; + --color-text-tertiary: #6f7c94; + --color-text-muted: #6f7c94; + --color-text-inverse: #0a0d12; + + /* Interactive accent */ + --color-accent: #8fb4ff; + --color-accent-hover: #b4ccff; + --color-accent-subtle: rgba(143, 180, 255, 0.16); + + /* Status */ + --color-status-working: #6e8fff; + --color-status-ready: #73e0aa; + --color-status-attention: #f1be64; + --color-status-idle: #293142; + --color-status-done: #202838; + --color-status-error: #ff7b72; + + /* Semantic aliases */ + --color-accent-blue: #8fb4ff; + --color-accent-green: #5fd39a; + --color-accent-yellow: #f1be64; + --color-accent-orange: #ff9d57; + --color-accent-red: #ff7b72; + --color-accent-violet: #b59cff; + --color-accent-purple: #b59cff; + + /* Theme-adaptive tints */ + --color-tint-blue: rgba(143, 180, 255, 0.12); + --color-tint-green: rgba(95, 211, 154, 0.12); + --color-tint-yellow: rgba(241, 190, 100, 0.12); + --color-tint-red: rgba(255, 123, 114, 0.12); + --color-tint-violet: rgba(181, 156, 255, 0.12); + --color-tint-orange: rgba(255, 157, 87, 0.12); + --color-tint-neutral: rgba(255, 255, 255, 0.05); + + /* Chip/badge background */ + --color-chip-bg: rgba(255, 255, 255, 0.06); + + /* Hover overlay */ + --color-hover-overlay: rgba(143, 180, 255, 0.05); + + /* Scrollbar */ + --color-scrollbar: rgba(255, 255, 255, 0.08); + --color-scrollbar-hover: rgba(255, 255, 255, 0.15); + --color-scrollbar-active: rgba(255, 255, 255, 0.25); + + /* Body */ + --color-body-gradient-blue: rgba(110, 143, 255, 0.2); + --color-body-gradient-violet: rgba(91, 208, 177, 0.08); + + /* Nav glass */ + --color-nav-bg: rgba(10, 13, 18, 0.82); + + /* Card surfaces */ + --card-bg: linear-gradient(180deg, rgba(22, 28, 37, 0.98) 0%, rgba(16, 21, 29, 0.98) 100%); + --card-merge-bg: rgba(17, 23, 31, 0.98); + --card-expanded-bg: linear-gradient( + 180deg, + rgba(26, 34, 45, 0.98) 0%, + rgba(18, 24, 33, 0.98) 100% + ); + --card-shadow: 0 18px 36px rgba(2, 6, 12, 0.24); + --card-shadow-hover: 0 24px 54px rgba(2, 6, 12, 0.34); + --card-inset: inset 0 1px 0 rgba(255, 255, 255, 0.04); + --card-border: rgba(166, 190, 226, 0.18); + + /* Done card surfaces */ + --card-done-bg: linear-gradient(180deg, rgba(18, 24, 33, 0.72) 0%, rgba(14, 19, 27, 0.72) 100%); + --card-done-border: rgba(160, 190, 255, 0.1); + + --done-pill-exited-bg: rgba(143, 180, 255, 0.08); + --done-pill-exited-color: #8a9ab6; + --done-pill-merged-bg: rgba(95, 211, 154, 0.14); + --done-pill-merged-color: #5fd39a; + --done-pill-killed-bg: rgba(255, 123, 114, 0.12); + --done-pill-killed-color: #ff7b72; + --done-meta-chip-bg: rgba(143, 180, 255, 0.05); + --done-meta-chip-border: rgba(160, 190, 255, 0.1); + --done-section-border: rgba(160, 190, 255, 0.08); + --done-title-color: #9ba8bf; + --done-restore-bg: rgba(143, 180, 255, 0.1); + --done-restore-border: rgba(143, 180, 255, 0.24); + --done-restore-hover-bg: rgba(143, 180, 255, 0.18); + + /* Detail card */ + --detail-card-bg: linear-gradient(180deg, rgba(20, 26, 35, 0.98) 0%, rgba(14, 19, 27, 0.98) 100%); + --detail-card-shadow: 0 18px 42px rgba(0, 0, 0, 0.28); + + /* Orchestrator button */ + --btn-shadow: 0 10px 24px rgba(34, 63, 116, 0.16); + --btn-shadow-hover: 0 16px 34px rgba(34, 63, 116, 0.22); + --btn-inset: inset 0 1px 0 rgba(255, 255, 255, 0.08); + + /* Kanban column */ + --color-column-bg: #0d1218; + --color-column-header: transparent; + + /* Alert colors — Linear-muted */ + --color-alert-ci: #7b85e0; + --color-alert-ci-bg: rgba(94, 106, 210, 0.25); + --color-alert-ci-unknown: #d4a72c; + --color-alert-review: #4dab6e; + --color-alert-review-bg: rgba(77, 171, 110, 0.25); + --color-alert-changes: #9b8afb; + --color-alert-changes-bg: rgba(155, 138, 251, 0.25); + --color-alert-conflict: #d4a72c; + --color-alert-conflict-bg: rgba(212, 167, 44, 0.25); + --color-alert-comment: #c88a2e; + --color-alert-comment-bg: rgba(200, 138, 46, 0.25); + + /* Activity pulse glow */ + --color-activity-pulse: rgba(94, 106, 210, 0.45); + --color-activity-pulse-end: rgba(94, 106, 210, 0); +} + +@theme { /* ── Typography ──────────────────────────────────────────────────── */ --font-sans: var(--font-ibm-plex-sans), "SF Pro Text", -apple-system, system-ui, sans-serif; --font-mono: var(--font-ibm-plex-mono), "SF Mono", "Menlo", "Consolas", monospace; - /* ── Border radius ────────────────────────────────────────────────── */ + /* ── Border radius ───────────────────────────────────────────────── */ --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-xl: 12px; /* ── Transitions ──────────────────────────────────────────────────── */ - --transition-quick: 0.1s; + --transition-quick: 0.1s; --transition-regular: 0.25s; } +/* ── Reference tokens (outside @theme to avoid Tailwind namespace collisions) ── */ + +:root { + /* Spacing reference tokens — in :root to avoid potential Tailwind utility conflicts */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; + + /* Z-index scale — in :root; Tailwind v4 z-index namespace is --z-index-*, not --z-* */ + --z-base: 0; + --z-raised: 10; + --z-nav: 100; + --z-modal: 200; + --z-overlay: 300; + --z-toast: 400; + + /* Font-size scale — in :root to avoid --font-* font-family namespace in @theme */ + --font-size-xs: 10px; + --font-size-sm: 11px; + --font-size-base: 13px; + --font-size-lg: 15px; + --font-size-xl: 17px; + + /* Shadow levels — in :root to avoid --shadow-* utility namespace in @theme */ + --box-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.6); + --box-shadow-md: 0 1px 3px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.05); + --box-shadow-lg: + 0 1px 2px rgba(0, 0, 0, 0.9), 0 3px 10px rgba(0, 0, 0, 0.55), + inset 0 1px 0 rgba(255, 255, 255, 0.07); + --box-shadow-xl: + 0 4px 8px rgba(0, 0, 0, 0.85), 0 14px 36px rgba(0, 0, 0, 0.55), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + + /* Gradient surfaces */ + --gradient-session-card: linear-gradient( + 175deg, + rgba(30, 30, 35, 1) 0%, + rgba(23, 23, 27, 1) 100% + ); + --gradient-session-card-expanded: linear-gradient( + 175deg, + rgba(38, 38, 44, 1) 0%, + rgba(30, 30, 35, 1) 100% + ); + --gradient-session-card-merge: linear-gradient( + 175deg, + rgba(23, 29, 25, 1) 0%, + rgba(17, 22, 19, 1) 100% + ); + --gradient-detail-card: linear-gradient(175deg, rgba(30, 30, 35, 1) 0%, rgba(23, 23, 27, 1) 100%); + --gradient-orchestrator-btn: linear-gradient( + 175deg, + rgba(94, 106, 210, 0.12) 0%, + rgba(94, 106, 210, 0.06) 100% + ); + --gradient-orchestrator-btn-hover: linear-gradient( + 175deg, + rgba(94, 106, 210, 0.18) 0%, + rgba(94, 106, 210, 0.1) 100% + ); + --gradient-status-strip: linear-gradient( + to bottom, + rgba(94, 106, 210, 0.04) 0%, + transparent 100% + ); +} + /* ── Base styles ─────────────────────────────────────────────────────── */ body { font-family: var(--font-sans); - background: - radial-gradient(ellipse 70% 40% at 15% 0%, rgba(88, 166, 255, 0.05) 0%, transparent 60%), - radial-gradient(ellipse 50% 30% at 85% 90%, rgba(163, 113, 247, 0.04) 0%, transparent 60%), - var(--color-bg-base); + background: var(--color-bg-base); color: var(--color-text-primary); min-height: 100vh; letter-spacing: -0.011em; } +/* Light mode: solid white, NO gradient */ +html:not(.dark) body { + background: #ffffff; +} + +/* Radial gradient only for dark mode */ +.dark body { + background: + radial-gradient(ellipse 70% 40% at 15% 0%, var(--color-body-gradient-blue) 0%, transparent 60%), + radial-gradient( + ellipse 50% 30% at 85% 90%, + var(--color-body-gradient-violet) 0%, + transparent 60% + ), + linear-gradient(180deg, rgba(255, 255, 255, 0.015) 0%, transparent 28%), var(--color-bg-base); +} + a { color: var(--color-accent); text-decoration: none; @@ -88,119 +423,1454 @@ a:hover { /* ── Scrollbar ────────────────────────────────────────────────────────── */ -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.15); } -::-webkit-scrollbar-thumb:active { background: rgba(255,255,255,0.25); } +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--color-scrollbar); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-scrollbar-hover); +} +::-webkit-scrollbar-thumb:active { + background: var(--color-scrollbar-active); +} + +/* ── xterm.js terminal viewport scrollbar — macOS-style auto-hide ──── */ + +.xterm .xterm-viewport { + overflow-y: overlay !important; +} + +.xterm .xterm-viewport::-webkit-scrollbar { + width: 5px; +} +.xterm .xterm-viewport::-webkit-scrollbar-track { + background: transparent; +} + +/* Dark — invisible by default, visible on hover */ +.xterm .xterm-viewport::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0); + border-radius: 2.5px; + transition: background 0.3s ease; +} +.xterm .xterm-viewport:hover::-webkit-scrollbar-thumb, +.xterm .xterm-viewport:active::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); +} +.xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.22); +} +.xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { + background: rgba(255, 255, 255, 0.35); +} + +/* Light — same pattern, darker thumb */ +html.light .xterm .xterm-viewport::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0); + transition: background 0.3s ease; +} +html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb, +html.light .xterm .xterm-viewport:active::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); +} +html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.28); +} +html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { + background: rgba(0, 0, 0, 0.4); +} /* ── Animations ──────────────────────────────────────────────────────── */ +@keyframes progress-shimmer { + 0% { + transform: translateX(-200%); + } + 100% { + transform: translateX(500%); + } +} + @keyframes activity-pulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(88, 166, 255, 0.45); } - 50% { box-shadow: 0 0 0 4px rgba(88, 166, 255, 0); } + 0%, + 100% { + box-shadow: 0 0 0 0 var(--color-activity-pulse); + } + 50% { + box-shadow: 0 0 0 4px var(--color-activity-pulse-end); + } } @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } } @keyframes slide-up { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes ready-sheen { + 0% { + transform: translateX(-160%); + opacity: 0; + } + 18% { + opacity: 0.28; + } + 100% { + transform: translateX(220%); + opacity: 0; + } +} + +@keyframes ready-rail-breathe { + 0%, + 100% { + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 4%, transparent), + 0 18px 34px color-mix(in srgb, var(--color-status-ready) 10%, transparent); + } + 50% { + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 4%, transparent), + 0 18px 38px color-mix(in srgb, var(--color-status-ready) 14%, transparent); + } +} + +@keyframes ready-dot-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 0.95; + } + 50% { + transform: scale(1.22); + opacity: 1; + } +} + +/* ── Kanban card entrance ─────────────────────────────────────────────── */ + +.kanban-card-enter { + animation: slide-up 0.18s ease forwards; } /* ── Orchestrator button ──────────────────────────────────────────────── */ .orchestrator-btn { + border-radius: 0; color: var(--color-accent); - background: linear-gradient(175deg, rgba(88,166,255,0.12) 0%, rgba(88,166,255,0.06) 100%); - border: 1px solid rgba(88,166,255,0.25); - box-shadow: - 0 1px 2px rgba(0,0,0,0.6), - 0 3px 8px rgba(0,0,0,0.3), - inset 0 1px 0 rgba(255,255,255,0.08); - transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease, border-color 0.12s ease; + background: linear-gradient( + 175deg, + color-mix(in srgb, var(--color-accent) 12%, transparent) 0%, + color-mix(in srgb, var(--color-accent) 6%, transparent) 100% + ); + border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent); + box-shadow: var(--btn-shadow); + transition: + transform 0.12s ease, + box-shadow 0.12s ease, + background 0.12s ease, + border-color 0.12s ease; } .orchestrator-btn:hover { transform: translateY(-1px); - background: linear-gradient(175deg, rgba(88,166,255,0.18) 0%, rgba(88,166,255,0.1) 100%); - border-color: rgba(88,166,255,0.45); - box-shadow: - 0 2px 6px rgba(0,0,0,0.7), - 0 8px 20px rgba(0,0,0,0.35), - 0 0 20px rgba(88,166,255,0.12), - inset 0 1px 0 rgba(255,255,255,0.12); + background: linear-gradient( + 175deg, + color-mix(in srgb, var(--color-accent) 18%, transparent) 0%, + color-mix(in srgb, var(--color-accent) 10%, transparent) 100% + ); + border-color: color-mix(in srgb, var(--color-accent) 45%, transparent); + box-shadow: var(--btn-shadow-hover); } /* ── Glass nav ────────────────────────────────────────────────────────── */ .nav-glass { - background: rgba(13, 17, 23, 0.88); + background: var(--color-nav-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); } +/* ── Dashboard shell ───────────────────────────────────────────────── */ + +.dashboard-shell { + position: relative; +} + +.dashboard-main { + position: relative; +} + +.dashboard-main::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 140px; + pointer-events: none; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.015) 0%, transparent 100%); +} + +.dashboard-hero { + position: relative; + overflow: visible; + border: 1px solid var(--color-border-default); + border-radius: 0; + background: var(--color-bg-surface); + box-shadow: 0 10px 24px rgba(2, 6, 12, 0.06); +} + +.dashboard-hero__backdrop { + display: none; +} + +.dashboard-hero__content { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 14px 18px; +} + +.dashboard-hero__primary { + display: flex; + flex: 1 1 720px; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.dashboard-hero__heading { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 760px; + min-width: 0; +} + +.dashboard-eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + width: fit-content; + padding: 4px 8px; + border: 1px solid color-mix(in srgb, var(--color-accent) 18%, var(--color-border-default)); + background: color-mix(in srgb, var(--color-accent) 8%, transparent); + color: var(--color-accent); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.dashboard-eyebrow__dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 18px currentColor; +} + +.dashboard-title { + font-size: clamp(22px, 2.8vw, 32px); + line-height: 1; + letter-spacing: -0.05em; + font-weight: 600; + color: var(--color-text-primary); +} + +.dashboard-subtitle { + margin-top: 3px; + max-width: 56ch; + font-size: 11px; + line-height: 1.4; + color: var(--color-text-muted); +} + +.dashboard-hero__meta { + display: flex; + flex: 0 0 auto; + flex-direction: row; + align-items: center; + gap: 10px; + margin-left: auto; +} + +.dashboard-stat-cards { + display: flex; + flex-wrap: wrap; + flex-shrink: 0; + align-items: center; + gap: 8px; + max-width: none; + margin-left: auto; +} + +.dashboard-stat-card { + display: inline-flex; + min-height: 0; + min-width: 0; + flex-direction: row; + align-items: baseline; + gap: 6px; + border: 1px solid color-mix(in srgb, var(--color-border-default) 88%, transparent); + border-radius: 0; + background: color-mix(in srgb, var(--color-bg-base) 72%, transparent); + padding: 6px 9px; + box-shadow: none; +} + +.dashboard-stat-card--empty { + max-width: 180px; +} + +.dashboard-stat-card__value { + font-size: 18px; + line-height: 1; + font-weight: 600; + letter-spacing: -0.05em; + font-variant-numeric: tabular-nums; +} + +.dashboard-stat-card__label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +.dashboard-stat-card__meta { + display: none; + color: var(--color-text-muted); +} + +.dashboard-alert { + border-radius: 0; + box-shadow: 0 16px 34px rgba(0, 0, 0, 0.08); +} + /* ── Detail page cards — subtle depth, no hover lift ─────────────────── */ .detail-card { - background: linear-gradient(175deg, rgba(24,31,40,1) 0%, rgba(17,22,29,1) 100%); - box-shadow: - 0 1px 3px rgba(0,0,0,0.6), - inset 0 1px 0 rgba(255,255,255,0.05); - --color-text-secondary: #8b949e; - --color-text-muted: #656d76; - --color-text-tertiary: #656d76; + background: var(--detail-card-bg); + box-shadow: var(--detail-card-shadow); + border-radius: 0; } -/* ── Session cards ────────────────────────────────────────────────────── */ +.session-detail-page { + background: + radial-gradient( + circle at top right, + color-mix(in srgb, var(--color-accent) 6%, transparent), + transparent 28% + ), + var(--color-bg-base); +} + +.session-page-header { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 10px; + border: 1px solid var(--color-border-default); + padding: 14px 16px; + background: var(--detail-card-bg); + box-shadow: var(--detail-card-shadow); +} + +@media (min-width: 900px) { + .session-page-header { + gap: 12px; + } +} + +.session-page-header__crumbs { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding-bottom: 10px; + border-bottom: 1px solid var(--color-border-subtle); +} + +.session-page-header__mode { + padding: 2px 8px; + border: 1px solid color-mix(in srgb, var(--color-accent) 20%, transparent); + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 10%, transparent); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.05em; +} + +.session-page-header__main { + display: grid; + gap: 12px; +} + +.session-page-header__identity { + min-width: 0; +} + +.session-page-header__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.session-page-header__side { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +@media (min-width: 1100px) { + .session-page-header__main { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + } + + .session-page-header__side { + justify-content: flex-end; + } +} + +.session-detail-link-pill { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--color-border-subtle); + border-radius: 0; + background: color-mix(in srgb, var(--color-bg-elevated) 88%, transparent); + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 500; +} + +.session-detail-link-pill { + transition: + border-color 0.14s ease, + color 0.14s ease, + background 0.14s ease; +} + +.session-detail-link-pill:hover { + border-color: var(--color-border-strong); + color: var(--color-text-primary); +} + +.session-detail-link-pill--accent { + color: var(--color-accent); + border-color: color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle)); + background: color-mix(in srgb, var(--color-accent) 8%, transparent); +} + +.dark .detail-card { + --color-text-secondary: #9898a0; + --color-text-muted: #5c5c66; + --color-text-tertiary: #5c5c66; +} + +/* ── Session cards — flat, Linear-style ──────────────────────────────── */ .session-card { - background: linear-gradient(175deg, rgba(28,36,47,1) 0%, rgba(18,23,31,1) 100%); + background: var(--card-bg); + border-color: var(--card-border); + border-radius: 0; box-shadow: - 0 1px 2px rgba(0,0,0,0.9), - 0 3px 10px rgba(0,0,0,0.55), - inset 0 1px 0 rgba(255,255,255,0.07); - transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease; + inset 0 1px 0 color-mix(in srgb, white 4%, transparent), + 0 10px 20px rgba(0, 0, 0, 0.08); + transition: + transform 0.12s ease, + border-color 0.12s ease, + background 0.12s ease, + box-shadow 0.12s ease; +} - /* Bump dim text colors inside the card — the elevated surface is darker - than the transparent bg these values were calibrated for */ - --color-text-secondary: #8b949e; - --color-text-muted: #656d76; - --color-text-tertiary: #656d76; +.session-card--fixed { + display: flex; + height: 242px; + flex-direction: column; + overflow: hidden; +} + +.session-card--alert-frame, +.session-card--merge-frame { + display: flex; + height: auto; + flex-direction: column; + overflow: hidden; +} + +.session-card--alert-frame { + min-height: 292px; +} + +.session-card--merge-frame { + min-height: 264px; +} + +.dark .session-card { + --color-text-secondary: #9898a0; + --color-text-muted: #5c5c66; + --color-text-tertiary: #5c5c66; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 14px 30px rgba(0, 0, 0, 0.34); +} + +/* ── Dark mode weight reduction ──────────────────────────────────────── + Light text on dark backgrounds appears visually heavier due to + subpixel antialiasing. Step down one weight level. */ + +.dark .kanban-column [class*="font-semibold"] { + font-weight: 500; +} +.dark .kanban-column [class*="font-bold"] { + font-weight: 600; +} +.dark .session-card-done .done-status-pill { + font-weight: 500; +} +.dark .session-card-done .done-detail-heading { + font-weight: 600; } .session-card:hover { - transform: translateY(-2px); + transform: translateY(-1px); + border-color: var(--color-border-strong); box-shadow: - 0 4px 8px rgba(0,0,0,0.85), - 0 14px 36px rgba(0,0,0,0.55), - inset 0 1px 0 rgba(255,255,255,0.1); + inset 0 1px 0 color-mix(in srgb, white 5%, transparent), + 0 18px 34px rgba(0, 0, 0, 0.09); +} + +.session-card__header, +.session-card__title-wrap, +.session-card__meta, +.session-card__actions { + position: relative; + z-index: 1; +} + +.session-card__title-wrap::after { + display: none; +} + +.session-card__title { + -webkit-line-clamp: 3; +} + +.session-card__secondary { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-height: 1.45; +} + +.session-card__alert-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.session-card__alert-pill { + max-width: 100%; +} + +.session-card__footer { + margin-top: auto; + min-height: 34px; + background: color-mix(in srgb, var(--color-bg-base) 35%, transparent); +} + +.session-card__control { + height: 28px; + padding-left: 10px; + padding-right: 10px; + box-sizing: border-box; + white-space: nowrap; +} + +.session-card__control-icon { + width: 12px; + height: 12px; + flex-shrink: 0; +} + +.session-card__terminate { + border-radius: 0; + line-height: 1; + padding-left: 8px; + padding-right: 8px; +} + +.session-card__merge-control { + border-color: color-mix(in srgb, var(--color-status-ready) 68%, transparent); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-status-ready) 34%, transparent) 0%, + color-mix(in srgb, var(--color-status-ready) 18%, transparent) 100% + ); + color: var(--color-status-ready); + text-decoration: none; + font-weight: 600; + letter-spacing: 0.01em; + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 26%, transparent), + 0 8px 18px color-mix(in srgb, var(--color-status-ready) 16%, transparent); +} + +.session-card__merge-control:hover { + border-color: var(--color-status-ready); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-status-ready) 46%, transparent) 0%, + color-mix(in srgb, var(--color-status-ready) 24%, transparent) 100% + ); + text-decoration: none; + transform: translateY(-1px); + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 30%, transparent), + 0 10px 22px color-mix(in srgb, var(--color-status-ready) 20%, transparent); +} + +.session-card__merge-control .session-card__control-icon { + transition: transform 0.14s ease; +} + +.session-card__merge-control:hover .session-card__control-icon { + transform: translateX(2px); } .session-card.card-merge-ready { - background: linear-gradient(175deg, rgba(16,40,24,1) 0%, rgba(10,26,15,1) 100%); + position: relative; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-status-ready) 10%, var(--card-merge-bg)) 0%, + color-mix(in srgb, var(--color-status-ready) 6%, var(--card-merge-bg)) 28%, + color-mix(in srgb, var(--color-status-ready) 3%, var(--card-merge-bg)) 100% + ); + border-color: color-mix(in srgb, var(--color-status-ready) 30%, var(--color-border-default)); box-shadow: - 0 0 0 1px rgba(63,185,80,0.22), - 0 1px 2px rgba(0,0,0,0.9), - 0 3px 10px rgba(0,0,0,0.55), - 0 0 40px rgba(63,185,80,0.13), - inset 0 1px 0 rgba(255,255,255,0.07); + inset 0 1px 0 color-mix(in srgb, white 4%, transparent), + 0 22px 40px color-mix(in srgb, var(--color-status-ready) 18%, transparent); + animation: ready-rail-breathe 3.8s ease-in-out infinite; +} + +.session-card.card-merge-ready::before { + display: none; +} + +.session-card.card-merge-ready::after { + content: ""; + position: absolute; + inset: -14px -10px -18px -10px; + pointer-events: none; + z-index: 0; + background: + radial-gradient( + ellipse at 50% 100%, + color-mix(in srgb, var(--color-status-ready) 18%, transparent) 0%, + color-mix(in srgb, var(--color-status-ready) 8%, transparent) 42%, + transparent 72% + ), + radial-gradient( + ellipse at 50% 0%, + color-mix(in srgb, var(--color-status-ready) 10%, transparent) 0%, + transparent 58% + ); + filter: blur(10px); + opacity: 0.9; +} + +.session-card.card-merge-ready > * { + position: relative; + z-index: 1; +} + +.dark .session-card.card-merge-ready::after { + background: + radial-gradient( + ellipse at 50% 100%, + color-mix(in srgb, var(--color-status-ready) 24%, transparent) 0%, + color-mix(in srgb, var(--color-status-ready) 10%, transparent) 44%, + transparent 72% + ), + radial-gradient( + ellipse at 50% 0%, + color-mix(in srgb, var(--color-status-ready) 12%, transparent) 0%, + transparent 58% + ); + opacity: 0.75; +} + +.session-card.card-merge-ready:hover::after { + opacity: 1; + filter: blur(12px); + background: + radial-gradient( + ellipse at 50% 100%, + color-mix(in srgb, var(--color-status-ready) 24%, transparent) 0%, + color-mix(in srgb, var(--color-status-ready) 10%, transparent) 44%, + transparent 74% + ), + radial-gradient( + ellipse at 50% 0%, + color-mix(in srgb, var(--color-status-ready) 12%, transparent) 0%, + transparent 58% + ); +} + +.dark .session-card.card-merge-ready:hover::after { + background: + radial-gradient( + ellipse at 50% 100%, + color-mix(in srgb, var(--color-status-ready) 28%, transparent) 0%, + color-mix(in srgb, var(--color-status-ready) 12%, transparent) 44%, + transparent 74% + ), + radial-gradient( + ellipse at 50% 0%, + color-mix(in srgb, var(--color-status-ready) 14%, transparent) 0%, + transparent 58% + ); +} + +.session-card.card-merge-ready::after { + transition: + opacity 0.18s ease, + filter 0.18s ease, + background 0.18s ease; } .session-card.card-merge-ready:hover { transform: translateY(-2px); + border-color: color-mix(in srgb, var(--color-status-ready) 42%, var(--color-border-strong)); box-shadow: - 0 0 0 1px rgba(63,185,80,0.35), - 0 4px 8px rgba(0,0,0,0.85), - 0 14px 36px rgba(0,0,0,0.55), - 0 0 60px rgba(63,185,80,0.2), - inset 0 1px 0 rgba(255,255,255,0.1); + inset 0 1px 0 color-mix(in srgb, white 6%, transparent), + 0 28px 52px color-mix(in srgb, var(--color-status-ready) 22%, transparent); +} + +.session-card.card-merge-ready .session-card__header > span:first-child > span:first-child { + animation: ready-dot-pulse 2.8s ease-in-out infinite; +} + +/* ── Done cards — compact, de-emphasized ─────────────────────────────── */ + +.session-card-done { + background: var(--card-done-bg); + border: 1px solid var(--card-done-border); + border-radius: 0; + transition: + border-color 0.15s ease, + background 0.15s ease, + box-shadow 0.15s ease; + cursor: pointer; + overflow: hidden; +} + +.session-card-done:hover { + border-color: var(--color-border-strong); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.dark .session-card-done:hover { + box-shadow: none; +} + +.session-card-done.done-expanded { + border-color: var(--color-border-strong); +} + +/* Status pill inside done cards */ +.done-status-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 0; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1.4; +} + +.done-status-pill--exited { + background: var(--done-pill-exited-bg); + color: var(--done-pill-exited-color); +} + +.done-status-pill--merged { + background: var(--done-pill-merged-bg); + color: var(--done-pill-merged-color); +} + +.done-status-pill--killed { + background: var(--done-pill-killed-bg); + color: var(--done-pill-killed-color); +} + +/* Meta chip inside done cards */ +.done-meta-chip { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 7px; + border-radius: 0; + background: var(--done-meta-chip-bg); + border: 1px solid var(--done-meta-chip-border); + font-size: 10px; + font-weight: 500; + color: var(--color-text-muted); +} + +/* Restore button inside done cards */ +.done-restore-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 0; + border: 1px solid var(--done-restore-border); + background: var(--done-restore-bg); + color: var(--color-accent); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: + background 0.12s ease, + border-color 0.12s ease; +} + +.done-restore-btn:hover { + background: var(--done-restore-hover-bg); + border-color: color-mix(in srgb, var(--color-accent) 40%, transparent); +} + +/* Done card expand section */ +.done-expand-section { + border-top: 1px solid var(--done-section-border); + animation: done-slide-in 0.15s ease-out; +} + +@keyframes done-slide-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Done detail section heading */ +.done-detail-heading { + display: flex; + align-items: center; + gap: 5px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-tertiary); + margin-bottom: 6px; +} + +.done-detail-heading svg { + width: 11px; + height: 11px; + opacity: 0.6; +} + +/* ── Project sidebar ────────────────────────────────────────────────── */ + +.project-sidebar nav::-webkit-scrollbar { + width: 4px; +} +.project-sidebar nav::-webkit-scrollbar-track { + background: transparent; +} +.project-sidebar nav::-webkit-scrollbar-thumb { + background: var(--color-scrollbar); + border-radius: 2px; +} + +.project-sidebar { + border-right: 1px solid var(--color-border-subtle); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-bg-elevated) 92%, black 8%) 0%, + color-mix(in srgb, var(--color-bg-base) 90%, black 10%) 100% + ); +} + +.project-sidebar--collapsed { + align-items: center; +} + +.project-sidebar__header { + border-bottom: 1px solid var(--color-border-subtle); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-accent) 4%, transparent) 0%, + transparent 100% + ); +} + +.project-sidebar__eyebrow { + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-accent); +} + +.project-sidebar__title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-top: 6px; +} + +.project-sidebar__title { + font-size: 13px; + line-height: 1.1; + letter-spacing: -0.02em; + font-weight: 600; + color: var(--color-text-primary); +} + +.project-sidebar__subtitle { + margin-top: 3px; + max-width: 18ch; + font-size: 10px; + line-height: 1.4; + color: var(--color-text-muted); +} + +.project-sidebar__badge { + display: inline-flex; + min-width: 22px; + height: 22px; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border-default); + background: color-mix(in srgb, var(--color-bg-base) 42%, transparent); + font-size: 10px; + font-weight: 600; + color: var(--color-text-primary); + font-variant-numeric: tabular-nums; +} + +.project-sidebar__summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + margin-top: 10px; +} + +.project-sidebar__metric { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 6px 5px; + border: 1px solid var(--color-border-subtle); + background: color-mix(in srgb, var(--color-bg-base) 40%, transparent); +} + +.project-sidebar__metric-value { + font-size: 13px; + line-height: 1; + letter-spacing: -0.03em; + font-weight: 600; + color: var(--color-text-primary); + font-variant-numeric: tabular-nums; +} + +.project-sidebar__metric-label { + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.project-sidebar__divider { + border-top: 1px solid var(--color-border-subtle); +} + +.project-sidebar__item { + position: relative; + border: 1px solid transparent; + background: transparent; +} + +.project-sidebar__item:hover { + background: var(--color-hover-overlay); + border-color: var(--color-border-subtle); +} + +.project-sidebar__item--active { + border-color: color-mix(in srgb, var(--color-accent) 24%, var(--color-border-default)); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--color-accent) 16%, transparent), + transparent 90% + ); +} + +.project-sidebar__count { + border: 1px solid var(--color-border-subtle); + background: color-mix(in srgb, var(--color-bg-base) 40%, transparent); +} + +.project-sidebar__children { + position: relative; + margin-top: 4px; + border-left: 1px solid color-mix(in srgb, var(--color-border-subtle) 82%, transparent); +} + +.project-sidebar__session { + position: relative; + margin: 2px 0 2px 8px; + border: 1px solid transparent; + background: transparent; +} + +.project-sidebar__session:hover { + border-color: var(--color-border-subtle); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--color-hover-overlay) 90%, transparent), + transparent 100% + ); +} + +.project-sidebar__session--active { + border-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-border-subtle)); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--color-accent) 12%, transparent), + transparent 88% + ); + box-shadow: inset 2px 0 0 var(--color-accent); +} + +.project-sidebar__session::before { + content: ""; + position: absolute; + left: -9px; + top: 50%; + width: 8px; + height: 1px; + background: var(--color-border-subtle); + transform: translateY(-50%); +} + +.project-sidebar__session-tone { + flex-shrink: 0; + padding: 2px 6px; + border: 1px solid var(--color-border-subtle); + background: color-mix(in srgb, var(--color-bg-base) 40%, transparent); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.project-sidebar__session-id { + opacity: 0; + color: var(--color-text-tertiary); + transition: + opacity 0.15s ease, + color 0.15s ease; +} + +.project-sidebar__session:hover .project-sidebar__session-id, +.project-sidebar__session--active .project-sidebar__session-id { + opacity: 0.75; +} + +.project-sidebar__session--active .project-sidebar__session-tone { + border-color: color-mix(in srgb, var(--color-accent) 24%, var(--color-border-subtle)); + background: color-mix(in srgb, var(--color-accent) 10%, transparent); + color: var(--color-accent); +} + +.project-sidebar__collapse-btn, +.project-sidebar__collapsed-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + border: 1px solid var(--color-border-subtle); + background: color-mix(in srgb, var(--color-bg-base) 40%, transparent); + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 600; + transition: + border-color 0.12s ease, + background 0.12s ease, + color 0.12s ease; +} + +.project-sidebar__collapse-btn { + padding: 9px 10px; +} + +.project-sidebar__collapsed-toggle { + width: 36px; + height: 36px; +} + +.project-sidebar__collapse-btn:hover, +.project-sidebar__collapsed-toggle:hover { + border-color: var(--color-border-default); + background: var(--color-hover-overlay); + color: var(--color-text-primary); +} + +.project-sidebar__collapsed-project { + position: relative; + display: inline-flex; + width: 36px; + height: 36px; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border-subtle); + border-radius: 8px; + background: var(--color-bg-surface); + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease, + box-shadow 0.15s ease; +} + +.project-sidebar__collapsed-project:hover { + border-color: var(--color-border-default); + background: var(--color-bg-elevated); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.project-sidebar__collapsed-project--active { + border-color: color-mix(in srgb, var(--color-accent) 35%, var(--color-border-default)); + background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-elevated)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent) 12%, transparent); +} + +.project-sidebar__avatar { + font-size: 13px; + font-weight: 600; + line-height: 1; + color: var(--color-text-secondary); + user-select: none; +} + +.project-sidebar__collapsed-project--active .project-sidebar__avatar { + color: var(--color-accent); +} + +.project-sidebar__health-indicator { + position: absolute; + top: -2px; + right: -2px; + width: 7px; + height: 7px; + border-radius: 50%; + border: 1.5px solid var(--color-bg-surface); + box-sizing: content-box; +} + +/* ── Kanban board ────────────────────────────────────────────────────── */ + +.kanban-board { + display: flex; + gap: 8px; + height: calc(100vh - 280px); + overflow-x: auto; + padding-bottom: 8px; +} + +.kanban-board-wrap { + position: relative; + margin-top: 12px; +} + +.board-section-head { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; +} + +.board-section-head__title { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +.board-section-head__subtitle { + margin-top: 5px; + font-size: 12px; + color: var(--color-text-muted); +} + +.board-section-head__legend { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.board-legend-item { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--color-border-subtle); + border-radius: 0; + background: color-mix(in srgb, var(--color-bg-elevated) 88%, transparent); + padding: 6px 10px; + font-size: 11px; + color: var(--color-text-secondary); +} + +.board-legend-item__dot { + width: 7px; + height: 7px; + border-radius: 999px; +} + +.kanban-column { + flex: 1 0 260px; + min-width: 260px; + max-width: 420px; + display: flex; + flex-direction: column; + border: 1px solid var(--color-border-default); + border-radius: 0; + background: var(--color-column-bg); + padding: 4px; + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 2%, transparent), + 0 16px 34px rgba(0, 0, 0, 0.05); +} + +.kanban-column[data-level="merge"] { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-status-ready) 4%, var(--color-column-bg)) 0%, + var(--color-column-bg) 100% + ); +} + +.dark .kanban-column { + border-color: rgba(170, 195, 230, 0.14); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.02), + 0 18px 42px rgba(0, 0, 0, 0.24); +} + +.dark .kanban-column[data-level="merge"] { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-status-ready) 7%, var(--color-column-bg)) 0%, + var(--color-column-bg) 100% + ); +} + +.kanban-column__header { + margin-bottom: 14px; + padding: 2px 2px 0; +} + +.kanban-column__title-row { + display: flex; + align-items: center; + gap: 10px; +} + +.kanban-column__dot { + width: 8px; + height: 8px; + border-radius: 999px; +} + +.kanban-column__title { + font-size: 20px; + font-weight: 600; + letter-spacing: normal; + text-transform: none; + color: var(--color-text-primary); +} + +.kanban-column__count { + margin-left: auto; + min-width: 28px; + padding: 4px 8px; + border: 1px solid var(--color-border-subtle); + border-radius: 0; + background: color-mix(in srgb, var(--color-bg-base) 72%, transparent); + font-size: 11px; + font-weight: 700; + line-height: 1; + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; + text-align: center; +} + +.kanban-column__caption { + display: block; + margin-top: 7px; + font-size: 12px; + line-height: 1.5; + color: var(--color-text-muted); +} + +.kanban-column-body { + flex: 1; + overflow-y: auto; + padding: 2px; +} + +.kanban-column-body::-webkit-scrollbar { + width: 4px; +} +.kanban-column-body::-webkit-scrollbar-track { + background: transparent; +} +.kanban-column-body::-webkit-scrollbar-thumb { + background: var(--color-scrollbar); + border-radius: 2px; +} + +/* ── Session card zone glow (hover) ──────────────────────────────────── */ + +.kanban-column__empty { + display: flex; + min-height: 132px; + align-items: center; + justify-content: center; + border: 1px dashed color-mix(in srgb, var(--color-border-default) 82%, transparent); + border-radius: 0; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--color-bg-base) 28%, transparent) 0%, + transparent 100% + ); +} + +.kanban-column__empty-label { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: none; + color: var(--color-text-tertiary); +} + +/* Card glow effects removed for Linear-clean look */ + +@media (max-width: 960px) { + .dashboard-hero__content { + padding: 12px 14px; + } + + .dashboard-hero__primary { + width: 100%; + flex-direction: column; + gap: 10px; + } + + .dashboard-hero__meta { + width: 100%; + align-items: flex-start; + justify-content: space-between; + } + + .dashboard-stat-cards { + width: 100%; + } + + .kanban-board { + height: auto; + min-height: calc(100vh - 280px); + } + + .session-card--fixed { + height: 232px; + } + + .session-card--alert-frame { + min-height: 280px; + } + + .session-card--merge-frame { + min-height: 252px; + } +} + +@media (max-width: 640px) { + .dashboard-title { + font-size: 26px; + } + + .dashboard-stat-cards { + gap: 6px; + } + + .kanban-column { + min-width: min(86vw, 320px); + } + + .session-card--fixed { + height: 224px; + } + + .session-card--alert-frame { + min-height: 268px; + } + + .session-card--merge-frame { + min-height: 244px; + } } diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 3b408cac2..bc339e4fd 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; -import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google"; +import { IBM_Plex_Sans, IBM_Plex_Mono, JetBrains_Mono } from "next/font/google"; +import { ThemeProvider } from "next-themes"; import { getProjectName } from "@/lib/project-name"; import "./globals.css"; @@ -17,6 +18,13 @@ const ibmPlexMono = IBM_Plex_Mono({ weight: ["300", "400", "500"], }); +const jetbrainsMono = JetBrains_Mono({ + subsets: ["latin"], + variable: "--font-jetbrains-mono", + display: "swap", + weight: ["400", "500"], +}); + export async function generateMetadata(): Promise { const projectName = getProjectName(); return { @@ -30,9 +38,11 @@ export async function generateMetadata(): Promise { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + - {children} + + {children} + ); diff --git a/packages/web/src/components/AttentionZone.tsx b/packages/web/src/components/AttentionZone.tsx index 26ac03726..420b95744 100644 --- a/packages/web/src/components/AttentionZone.tsx +++ b/packages/web/src/components/AttentionZone.tsx @@ -1,13 +1,12 @@ "use client"; -import { memo, useState } from "react"; +import { memo } from "react"; import type { DashboardSession, AttentionLevel } from "@/lib/types"; import { SessionCard } from "./SessionCard"; interface AttentionZoneProps { level: AttentionLevel; sessions: DashboardSession[]; - variant?: "column" | "grid"; onSend?: (sessionId: string, message: string) => void; onKill?: (sessionId: string) => void; onMerge?: (prNumber: number) => void; @@ -19,87 +18,68 @@ const zoneConfig: Record< { label: string; color: string; - defaultCollapsed: boolean; + caption: string; } > = { merge: { - label: "Merge", + label: "Ready", color: "var(--color-status-ready)", - defaultCollapsed: false, + caption: "Cleared to land", }, respond: { label: "Respond", color: "var(--color-status-error)", - defaultCollapsed: false, + caption: "Human judgment needed", }, review: { label: "Review", color: "var(--color-accent-orange)", - defaultCollapsed: false, + caption: "Code waiting on eyes", }, pending: { label: "Pending", color: "var(--color-status-attention)", - defaultCollapsed: false, + caption: "Blocked on system state", }, working: { label: "Working", color: "var(--color-status-working)", - defaultCollapsed: false, + caption: "Agents are actively moving", }, done: { label: "Done", color: "var(--color-text-tertiary)", - defaultCollapsed: true, + caption: "Completed or exited", }, }; +/** + * Kanban column — always renders (even when empty) to preserve + * the board shape. Cards scroll independently within each column. + */ function AttentionZoneView({ level, sessions, - variant = "grid", onSend, onKill, onMerge, onRestore, }: AttentionZoneProps) { const config = zoneConfig[level]; - const [collapsed, setCollapsed] = useState(config.defaultCollapsed); - if (sessions.length === 0) return null; + return ( +
+
+
+
+ {config.label} + {sessions.length} +
+

{config.caption}

+
- if (variant === "column") { - return ( -
- {/* Column header */} - - - {!collapsed && ( +
+ {sessions.length > 0 ? (
{sessions.map((session) => ( ))}
+ ) : ( +
+ No sessions +
)}
- ); - } - - return ( -
- {/* Zone header: [●] LABEL ──────────────────────────────── count [▾] */} - - - {!collapsed && ( -
- {sessions.map((session) => ( - - ))} -
- )}
); } @@ -170,7 +105,6 @@ function AttentionZoneView({ function areAttentionZonePropsEqual(prev: AttentionZoneProps, next: AttentionZoneProps): boolean { return ( prev.level === next.level && - prev.variant === next.variant && prev.onSend === next.onSend && prev.onKill === next.onKill && prev.onMerge === next.onMerge && diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 80cd66835..b30cf7bde 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; import { type DashboardSession, type DashboardStats, @@ -10,14 +11,16 @@ import { type DashboardOrchestratorLink, getAttentionLevel, isPRRateLimited, + CI_STATUS, } from "@/lib/types"; -import { CI_STATUS } from "@composio/ao-core/types"; import { AttentionZone } from "./AttentionZone"; import { PRTableRow } from "./PRStatus"; import { DynamicFavicon } from "./DynamicFavicon"; import { useSessionEvents } from "@/hooks/useSessionEvents"; import { ProjectSidebar } from "./ProjectSidebar"; +import { ThemeToggle } from "./ThemeToggle"; import type { ProjectInfo } from "@/lib/project-name"; +import { EmptyState } from "./Skeleton"; interface DashboardProps { initialSessions: DashboardSession[]; @@ -58,15 +61,23 @@ export function Dashboard({ initialGlobalPause, projectId, ); + const searchParams = useSearchParams(); + const activeSessionId = searchParams.get("session") ?? undefined; const [rateLimitDismissed, setRateLimitDismissed] = useState(false); const [globalPauseDismissed, setGlobalPauseDismissed] = useState(false); const [activeOrchestrators, setActiveOrchestrators] = useState(orchestratorLinks); const [spawningProjectIds, setSpawningProjectIds] = useState([]); const [spawnErrors, setSpawnErrors] = useState>({}); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const showSidebar = projects.length > 1; const allProjectsView = showSidebar && projectId === undefined; + const displaySessions = useMemo(() => { + if (allProjectsView || !activeSessionId) return sessions; + return sessions.filter((s) => s.id === activeSessionId); + }, [sessions, allProjectsView, activeSessionId]); + useEffect(() => { setActiveOrchestrators((current) => mergeOrchestrators(current, orchestratorLinks)); }, [orchestratorLinks]); @@ -80,11 +91,11 @@ export function Dashboard({ working: [], done: [], }; - for (const session of sessions) { + for (const session of displaySessions) { zones[getAttentionLevel(session)].push(session); } return zones; - }, [sessions]); + }, [displaySessions]); const sessionsByProject = useMemo(() => { const groupedSessions = new Map(); @@ -100,14 +111,14 @@ export function Dashboard({ }, [sessions]); const openPRs = useMemo(() => { - return sessions + return displaySessions .filter( (session): session is DashboardSession & { pr: DashboardPR } => session.pr?.state === "open", ) .map((session) => session.pr) .sort((a, b) => mergeScore(a) - mergeScore(b)); - }, [sessions]); + }, [displaySessions]); const projectOverviews = useMemo(() => { if (!allProjectsView) return []; @@ -214,7 +225,9 @@ export function Dashboard({ } }; - const hasKanbanSessions = KANBAN_LEVELS.some((level) => grouped[level].length > 0); + const hasAnySessions = KANBAN_LEVELS.some( + (level) => grouped[level].length > 0, + ); const anyRateLimited = useMemo( () => sessions.some((session) => session.pr && isPRRateLimited(session.pr)), @@ -245,22 +258,45 @@ export function Dashboard({ }, [globalPause?.pausedUntil, globalPause?.reason, globalPause?.sourceSessionId]); return ( -
- {showSidebar && } -
+
+ {showSidebar && ( + setSidebarCollapsed((current) => !current)} + /> + )} +
-
-
-

- {projectName ?? "Orchestrator"} -

- +
+
+
+
+
+
+

{projectName ?? "Orchestrator"}

+

+ Live sessions, review pressure, and merge readiness. +

+
+
+ +
+ +
+
+ {!allProjectsView && } + +
+
- {!allProjectsView && } -
+
{globalPause && !globalPauseDismissed && ( -
+
+
)} - {!allProjectsView && hasKanbanSessions && ( -
- {KANBAN_LEVELS.map((level) => - grouped[level].length > 0 ? ( -
- -
- ) : null, - )} + {!allProjectsView && hasAnySessions && ( +
+
+
+

Attention Board

+

+ Triage by required intervention, not by chronology. +

+
+
+ + + +
+
+
+ {KANBAN_LEVELS.map((level) => ( + + ))} +
)} - {!allProjectsView && grouped.done.length > 0 && ( -
- -
- )} + {!allProjectsView && !hasAnySessions && } {openPRs.length > 0 && (

Pull Requests

-
+
@@ -426,7 +461,7 @@ function OrchestratorControl({ orchestrators }: { orchestrators: DashboardOrches return ( orchestrator @@ -445,7 +480,7 @@ function OrchestratorControl({ orchestrators }: { orchestrators: DashboardOrches return (
- + {orchestrators.length} orchestrators -
+
{orchestrators.map((orchestrator, index) => ( (
@@ -523,7 +558,7 @@ function ProjectOverviewGrid({
Open project @@ -557,7 +592,7 @@ function ProjectOverviewGrid({ {orchestrator ? ( orchestrator @@ -567,7 +602,7 @@ function ProjectOverviewGrid({ type="button" onClick={() => void onSpawnOrchestrator(project)} disabled={spawningProjectIds.includes(project.id)} - className="orchestrator-btn rounded-[7px] px-3 py-1.5 text-[11px] font-semibold disabled:cursor-wait disabled:opacity-70" + className="orchestrator-btn px-3 py-1.5 text-[11px] font-semibold disabled:cursor-wait disabled:opacity-70" > {spawningProjectIds.includes(project.id) ? "Spawning..." : "Spawn Orchestrator"} @@ -587,7 +622,7 @@ function ProjectOverviewGrid({ function ProjectMetric({ label, value, tone }: { label: string; value: number; tone: string }) { return ( -
+
{label}
@@ -598,42 +633,63 @@ function ProjectMetric({ label, value, tone }: { label: string; value: number; t ); } -function StatusLine({ stats }: { stats: DashboardStats }) { +function StatusCards({ stats }: { stats: DashboardStats }) { if (stats.totalSessions === 0) { - return no sessions; + return ( +
+
+ Fleet + 0 + No live sessions +
+
+ ); } - const parts: Array<{ value: number; label: string; color?: string }> = [ - { value: stats.totalSessions, label: "sessions" }, - ...(stats.workingSessions > 0 - ? [{ value: stats.workingSessions, label: "working", color: "var(--color-status-working)" }] - : []), - ...(stats.openPRs > 0 ? [{ value: stats.openPRs, label: "PRs" }] : []), - ...(stats.needsReview > 0 - ? [{ value: stats.needsReview, label: "need review", color: "var(--color-status-attention)" }] - : []), + const parts: Array<{ value: number; label: string; meta: string; tone?: string }> = [ + { value: stats.totalSessions, label: "Fleet", meta: "Live sessions" }, + { + value: stats.workingSessions, + label: "Active", + meta: "Currently moving", + tone: "var(--color-status-working)", + }, + { value: stats.openPRs, label: "PRs", meta: "Open pull requests" }, + { + value: stats.needsReview, + label: "Review", + meta: "Awaiting eyes", + tone: "var(--color-status-attention)", + }, ]; return ( -
- {parts.map((part, index) => ( - - {index > 0 && ( - · - )} +
+ {parts.map((part) => ( +
{part.value} - {part.label} - + {part.label} + {part.meta} +
))}
); } +function BoardLegendItem({ label, tone }: { label: string; tone: string }) { + return ( + + + {label} + + ); +} + function mergeScore( pr: Pick, ): number { diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx index d14baf02e..d6c468bdd 100644 --- a/packages/web/src/components/DirectTerminal.tsx +++ b/packages/web/src/components/DirectTerminal.tsx @@ -1,14 +1,15 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useMemo } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; +import { useTheme } from "next-themes"; import { cn } from "@/lib/cn"; // Import xterm CSS (must be imported in client component) import "xterm/css/xterm.css"; // Dynamically import xterm types for TypeScript -import type { Terminal as TerminalType } from "xterm"; +import type { ITheme, Terminal as TerminalType } from "xterm"; import type { FitAddon as FitAddonType } from "@xterm/addon-fit"; interface DirectTerminalProps { @@ -37,6 +38,76 @@ interface DirectTerminalWsUrlOptions { directTerminalPort?: string; } +type TerminalVariant = "agent" | "orchestrator"; + +export function buildTerminalThemes(variant: TerminalVariant): { dark: ITheme; light: ITheme } { + const agentAccent = { + cursor: "#5b7ef8", + selDark: "rgba(91, 126, 248, 0.30)", + selLight: "rgba(91, 126, 248, 0.25)", + }; + const orchAccent = { + cursor: "#a371f7", + selDark: "rgba(163, 113, 247, 0.25)", + selLight: "rgba(130, 80, 223, 0.20)", + }; + const accent = variant === "orchestrator" ? orchAccent : agentAccent; + + const dark: ITheme = { + background: "#0a0a0f", + foreground: "#d4d4d8", + cursor: accent.cursor, + cursorAccent: "#0a0a0f", + selectionBackground: accent.selDark, + selectionInactiveBackground: "rgba(128, 128, 128, 0.2)", + // ANSI colors — slightly warmer than pure defaults + black: "#1a1a24", + red: "#ef4444", + green: "#22c55e", + yellow: "#f59e0b", + blue: "#5b7ef8", + magenta: "#a371f7", + cyan: "#22d3ee", + white: "#d4d4d8", + brightBlack: "#50506a", + brightRed: "#f87171", + brightGreen: "#4ade80", + brightYellow: "#fbbf24", + brightBlue: "#7b9cfb", + brightMagenta: "#c084fc", + brightCyan: "#67e8f9", + brightWhite: "#eeeef5", + }; + + const light: ITheme = { + background: "#fafafa", + foreground: "#24292f", + cursor: accent.cursor, + cursorAccent: "#fafafa", + selectionBackground: accent.selLight, + selectionInactiveBackground: "rgba(128, 128, 128, 0.15)", + // ANSI colors — darkened for legibility on #fafafa terminal background + black: "#24292f", + red: "#b42318", + green: "#1f7a3d", + yellow: "#8a5a00", + blue: "#175cd3", + magenta: "#8e24aa", + cyan: "#0b7285", + white: "#4b5563", + brightBlack: "#374151", + brightRed: "#912018", + brightGreen: "#176639", + brightYellow: "#6f4a00", + brightBlue: "#1d4ed8", + brightMagenta: "#7b1fa2", + brightCyan: "#155e75", + brightWhite: "#374151", + }; + + return { dark, light }; +} + export function buildDirectTerminalWsUrl({ location, sessionId, @@ -78,6 +149,8 @@ export function DirectTerminal({ const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const { resolvedTheme } = useTheme(); + const terminalThemes = useMemo(() => buildTerminalThemes(variant), [variant]); const terminalRef = useRef(null); const terminalInstance = useRef(null); @@ -164,45 +237,24 @@ export function DirectTerminal({ import("xterm").then((mod) => mod.Terminal), import("@xterm/addon-fit").then((mod) => mod.FitAddon), import("@xterm/addon-web-links").then((mod) => mod.WebLinksAddon), + document.fonts.ready, ]) .then(([Terminal, FitAddon, WebLinksAddon]) => { if (!mounted || !terminalRef.current) return; - // Cursor and selection color differ by variant: - // agent = blue (#5b7ef8), orchestrator = violet (#a371f7) - const cursorColor = variant === "orchestrator" ? "#a371f7" : "#5b7ef8"; - const selectionColor = - variant === "orchestrator" ? "rgba(163, 113, 247, 0.25)" : "rgba(91, 126, 248, 0.3)"; + const isDark = resolvedTheme !== "light"; + const activeTheme = isDark ? terminalThemes.dark : terminalThemes.light; // Initialize xterm.js Terminal const terminal = new Terminal({ cursorBlink: true, fontSize: 13, - fontFamily: '"IBM Plex Mono", "SF Mono", Menlo, Monaco, "Courier New", monospace', - theme: { - background: "#0a0a0f", - foreground: "#d4d4d8", - cursor: cursorColor, - cursorAccent: "#0a0a0f", - selectionBackground: selectionColor, - // ANSI colors — slightly warmer than pure defaults - black: "#1a1a24", - red: "#ef4444", - green: "#22c55e", - yellow: "#f59e0b", - blue: "#5b7ef8", - magenta: "#a371f7", - cyan: "#22d3ee", - white: "#d4d4d8", - brightBlack: "#50506a", - brightRed: "#f87171", - brightGreen: "#4ade80", - brightYellow: "#fbbf24", - brightBlue: "#7b9cfb", - brightMagenta: "#c084fc", - brightCyan: "#67e8f9", - brightWhite: "#eeeef5", - }, + fontFamily: + 'var(--font-jetbrains-mono), "JetBrains Mono", "SF Mono", Menlo, Monaco, "Courier New", monospace', + theme: activeTheme, + // Light mode needs an explicit contrast floor because agent UIs often emit + // dim/faint ANSI sequences that become unreadable on a near-white background. + minimumContrastRatio: isDark ? 1 : 7, scrollback: 10000, allowProposedApi: true, fastScrollModifier: "alt", @@ -457,6 +509,15 @@ export function DirectTerminal({ }; }, [sessionId, variant]); + // Live theme switching without terminal recreation + useEffect(() => { + const terminal = terminalInstance.current; + if (!terminal) return; + const isDark = resolvedTheme !== "light"; + terminal.options.theme = isDark ? terminalThemes.dark : terminalThemes.light; + terminal.options.minimumContrastRatio = isDark ? 1 : 7; + }, [resolvedTheme, terminalThemes]); + // Re-fit terminal when fullscreen changes useEffect(() => { const fit = fitAddon.current; @@ -571,8 +632,8 @@ export function DirectTerminal({ return (
@@ -589,7 +650,7 @@ export function DirectTerminal({ {/* XDA clipboard badge */} {reloading ? ( <> @@ -646,7 +707,7 @@ export function DirectTerminal({ + ); + })} +
+ + ); + } + + return ( +
); } diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index 08d4511d6..eb9ac2af5 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -3,18 +3,17 @@ import { memo, useState, useEffect, useRef } from "react"; import { type DashboardSession, - type AttentionLevel, getAttentionLevel, isPRRateLimited, TERMINAL_STATUSES, TERMINAL_ACTIVITIES, + CI_STATUS, } from "@/lib/types"; -import { CI_STATUS } from "@composio/ao-core/types"; import { cn } from "@/lib/cn"; import { getSessionTitle } from "@/lib/format"; -import { PRStatus } from "./PRStatus"; import { CICheckList } from "./CIBadge"; import { ActivityDot } from "./ActivityDot"; +import { getSizeLabel } from "./PRStatus"; interface SessionCardProps { session: DashboardSession; @@ -24,14 +23,73 @@ interface SessionCardProps { onRestore?: (sessionId: string) => void; } -const borderColorByLevel: Record = { - merge: "border-l-[var(--color-status-ready)]", - respond: "border-l-[var(--color-status-error)]", - review: "border-l-[var(--color-accent-orange)]", - pending: "border-l-[var(--color-status-attention)]", - working: "border-l-[var(--color-status-working)]", - done: "border-l-[var(--color-border-default)]", -}; +/** + * Determine the status display info for done cards. + */ +function getDoneStatusInfo(session: DashboardSession): { + label: string; + pillClass: string; + icon: React.ReactNode; +} { + const activity = session.activity; + const status = session.status; + const prState = session.pr?.state; + + if (prState === "merged" || status === "merged") { + return { + label: "merged", + pillClass: "done-status-pill--merged", + icon: ( + + + + ), + }; + } + + if (status === "killed" || status === "terminated") { + return { + label: status, + pillClass: "done-status-pill--killed", + icon: ( + + + + ), + }; + } + + // Default: exited / done / cleanup / closed PR + const label = activity === "exited" ? "exited" : status; + return { + label, + pillClass: "done-status-pill--exited", + icon: ( + + + + + ), + }; +} function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: SessionCardProps) { const [expanded, setExpanded] = useState(false); @@ -62,34 +120,236 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio const isRestorable = isTerminal && session.status !== "merged"; const title = getSessionTitle(session); + const isDone = level === "done"; + const secondaryText = session.issueLabel + ? `${session.issueLabel}${session.issueTitle ? ` · ${session.issueTitle}` : ""}` + : 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; + /* ── Done card variant ──────────────────────────────────────────── */ + if (isDone) { + const statusInfo = getDoneStatusInfo(session); + + return ( +
{ + if ((e.target as HTMLElement).closest("a, button, textarea")) return; + setExpanded(!expanded); + }} + > + {/* Row 1: Status pill + session id + restore */} +
+ + {statusInfo.icon} + {statusInfo.label} + + + {session.id} + +
+ {isRestorable && ( + + )} +
+ + {/* Row 2: Title */} +
+

+ {title} +

+
+ + {/* Row 3: Meta chips */} +
+ + {/* Expandable detail panel */} + {expanded && ( +
+ {session.summary && pr?.title && session.summary !== pr.title && ( +
+
+ + + + Summary +
+

+ {session.summary} +

+
+ )} + + {session.issueUrl && ( + + )} + + {pr && pr.ciChecks.length > 0 && ( +
+
+ + + + + CI Checks +
+ +
+ )} + + {pr && ( +
+
+ + + + + PR +
+

+ e.stopPropagation()} + className="hover:underline" + > + {pr.title} + +
+ + + +{pr.additions}{" "} + -{pr.deletions} + + · + + mergeable: {pr.mergeability.mergeable ? "yes" : "no"} + + · + + review: {pr.reviewDecision} + + +

+
+ )} + + {!pr && ( +

+ No PR associated with this session. +

+ )} + + {/* Action buttons — restore already shown in header row */} +
+ )} +
+ ); + } + + /* ── Standard card (non-done) ────────────────────────────────────── */ return (
{ - if ((e.target as HTMLElement).closest("a, button, textarea")) return; - setExpanded(!expanded); - }} + style={dynamicCardStyle} > {/* Header row: dot + session ID + terminal link */} -
- +
+ {isReadyToMerge ? : } {session.id} @@ -100,8 +360,18 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio e.stopPropagation(); onRestore?.(session.id); }} - className="rounded border border-[rgba(88,166,255,0.35)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[rgba(88,166,255,0.1)]" + className="inline-flex items-center gap-1 border border-[color-mix(in_srgb,var(--color-accent)_35%,transparent)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[var(--color-tint-blue)]" > + + + + restore )} @@ -109,106 +379,136 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio e.stopPropagation()} - className="rounded border border-[var(--color-border-default)] bg-[var(--color-bg-subtle)] px-2.5 py-0.5 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 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" > - terminal - - )} -
- - {/* Title — its own row, bigger, can wrap */} -
-

- {title} -

-
- - {/* Meta row: branch + PR pills */} -
- {session.branch && ( - - {session.branch} - - )} - {session.branch && pr && ( - · - )} - {pr && } -
- - {/* Rate limited indicator */} - {rateLimited && pr?.state === "open" && ( -
- - - + + + - PR data rate limited - -
- )} + terminal + + )} +
- {/* Merge button or alert tags */} - {!rateLimited && (alerts.length > 0 || isReadyToMerge) && ( -
- {isReadyToMerge && pr ? ( - - ) : ( -
- {alerts.map((alert) => ( - + PR data rate limited + +
+ )} + + {!rateLimited && alerts.length > 0 && ( +
+
+ {alerts.slice(0, 3).map((alert) => ( + e.stopPropagation()} className={cn( - "inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium hover:brightness-125 hover:no-underline", + "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 && {alert.count}} + {alert.count !== undefined && ( + <> + {alert.count}{" "} + + )} {alert.label} - {alert.actionLabel && session.activity !== "active" && ( + {alert.actionLabel && ( @@ -216,118 +516,73 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio ))}
- )} -
- )} - - {/* Expandable detail panel */} - {expanded && ( -
- {session.summary && pr?.title && session.summary !== pr.title && ( - -

- {session.summary} -

-
- )} - - {session.issueUrl && ( - - - {session.issueLabel || session.issueUrl} - {session.issueTitle && `: ${session.issueTitle}`} - - - )} - - {pr && pr.ciChecks.length > 0 && ( - - - - )} - - {pr && pr.unresolvedComments.length > 0 && ( - -
- {pr.unresolvedComments.map((c) => ( -
- - ● - - - {c.path} - - - view → - -
- ))} -
-
- )} - - {pr && ( - -

- - {pr.title} - -
- +{pr.additions}{" "} - -{pr.deletions} - {" · "}mergeable: {pr.mergeability.mergeable ? "yes" : "no"} - {" · "}review: {pr.reviewDecision} -

-
- )} - - {!pr && ( -

- No PR associated with this session. -

- )} - -
- {isRestorable && ( - - )} - {!isTerminal && ( - - )}
+ )} + +
+ {session.issueUrl ? ( + + {session.issueLabel || session.issueUrl} + + ) : ( + + {session.activity ?? session.status} + + )} + + {isReadyToMerge && pr ? ( + + ) : !isTerminal && ( + + )}
- )} +
); } @@ -344,25 +599,17 @@ function areSessionCardPropsEqual(prev: SessionCardProps, next: SessionCardProps export const SessionCard = memo(SessionCardView, areSessionCardPropsEqual); -function DetailSection({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
-
- {label} -
- {children} -
- ); -} - interface Alert { key: string; label: string; className: string; + color?: string; + borderColor?: string; url: string; count?: number; actionLabel?: string; actionMessage?: string; + actionClassName?: string; } function getAlerts(session: DashboardSession): Alert[] { @@ -379,19 +626,21 @@ function getAlerts(session: DashboardSession): Alert[] { alerts.push({ key: "ci-unknown", label: "CI unknown", - className: - "border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.08)] text-[var(--color-status-attention)]", + 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: - "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]", + className: "", + color: "var(--color-alert-ci)", + borderColor: "var(--color-alert-ci)", url: failedCheck?.url ?? pr.url + "/checks", 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", }); } } @@ -400,19 +649,23 @@ function getAlerts(session: DashboardSession): Alert[] { alerts.push({ key: "changes", label: "changes requested", - className: - "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]", + className: "", + color: "var(--color-alert-changes)", url: pr.url, + 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", label: "needs review", - className: - "border-[rgba(245,158,11,0.3)] bg-[rgba(245,158,11,0.08)] text-[var(--color-status-attention)]", + className: "", + color: "var(--color-alert-review)", url: pr.url, 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", }); } @@ -420,11 +673,12 @@ function getAlerts(session: DashboardSession): Alert[] { alerts.push({ key: "conflict", label: "merge conflict", - className: - "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]", + className: "", + color: "var(--color-alert-conflict)", url: pr.url, 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", }); } @@ -434,11 +688,13 @@ function getAlerts(session: DashboardSession): Alert[] { key: "comments", label: "unresolved comments", count: pr.unresolvedThreads, - className: - "border-[rgba(239,68,68,0.3)] bg-[rgba(239,68,68,0.08)] text-[var(--color-status-error)]", + className: "", + color: "var(--color-alert-comment)", + borderColor: "var(--color-alert-comment)", url: firstUrl, 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", }); } diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 3e3e33a92..b3d5aa377 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -1,13 +1,12 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, type ReactNode } from "react"; import { useSearchParams } from "next/navigation"; import { type DashboardSession, type DashboardPR, isPRMergeReady } from "@/lib/types"; import { CI_STATUS } from "@composio/ao-core/types"; import { cn } from "@/lib/cn"; import { CICheckList } from "./CIBadge"; import { DirectTerminal } from "./DirectTerminal"; -import { ActivityDot } from "./ActivityDot"; interface OrchestratorZones { merge: number; @@ -35,26 +34,8 @@ const activityMeta: Record = { exited: { label: "Exited", color: "var(--color-status-error)" }, }; -function humanizeStatus(status: string): string { - return status - .replace(/_/g, " ") - .replace(/\bci\b/gi, "CI") - .replace(/\bpr\b/gi, "PR") - .replace(/\b\w/g, (c) => c.toUpperCase()); -} - -function relativeTime(iso: string): string { - const ms = new Date(iso).getTime(); - if (!iso || isNaN(ms)) return "unknown"; - const diff = Date.now() - ms; - const seconds = Math.floor(diff / 1000); - if (seconds < 60) return "just now"; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; +function getSessionHeadline(session: DashboardSession): string { + return session.issueTitle ?? session.summary ?? session.id; } function cleanBugbotComment(body: string): { title: string; description: string } { @@ -75,8 +56,97 @@ function buildGitHubBranchUrl(pr: DashboardPR): string { return `https://github.com/${pr.owner}/${pr.repo}/tree/${pr.branch}`; } -function buildGitHubRepoUrl(pr: DashboardPR): string { - return `https://github.com/${pr.owner}/${pr.repo}`; +function SessionTopStrip({ + headline, + activityLabel, + activityColor, + branch, + pr, + isOrchestrator = false, + rightSlot, +}: { + headline: string; + activityLabel: string; + activityColor: string; + branch: string | null; + pr: DashboardPR | null; + isOrchestrator?: boolean; + rightSlot?: ReactNode; +}) { + return ( +
+
+ + + + + Orchestrator + + / + + {headline} + + {isOrchestrator ? orchestrator : null} +
+
+
+

+ {headline} +

+
+
+ + + {activityLabel} + +
+ {branch ? ( + pr ? ( + + {branch} + + ) : ( + + {branch} + + ) + ) : null} + {pr ? ( + + PR #{pr.number} + + ) : null} +
+
+ {rightSlot ?
{rightSlot}
: null} +
+
+ ); } async function askAgentToFix( @@ -106,9 +176,19 @@ async function askAgentToFix( function OrchestratorStatusStrip({ zones, createdAt, + headline, + activityLabel, + activityColor, + branch, + pr, }: { zones: OrchestratorZones; createdAt: string; + headline: string; + activityLabel: string; + activityColor: string; + branch: string | null; + pr: DashboardPR | null; }) { const [uptime, setUptime] = useState(""); @@ -137,52 +217,61 @@ function OrchestratorStatusStrip({ zones.merge + zones.respond + zones.review + zones.working + zones.pending + zones.done; return ( -
-
- {/* Total count */} -
- - {total} - - agents -
- -
- - {/* Per-zone pills */} - {stats.length > 0 ? ( - stats.map((s) => ( -
- - {s.value} - - - {s.label} +
+ +
+ + {total} + agents
- )) - ) : ( - no active agents - )} - {uptime && ( - - up {uptime} - - )} -
+
+ + {/* Per-zone pills */} + {stats.length > 0 ? ( + stats.map((s) => ( +
+ + {s.value} + + + {s.label} + +
+ )) + ) : ( + + no active agents + + )} + + {uptime && ( + + up {uptime} + + )} +
+ } + />
); } @@ -201,11 +290,12 @@ export function SessionDetail({ label: session.activity ?? "unknown", color: "var(--color-text-muted)", }; + const headline = getSessionHeadline(session); const accentColor = "var(--color-accent)"; const terminalVariant = isOrchestrator ? "orchestrator" : "agent"; - const terminalHeight = isOrchestrator ? "calc(100vh - 240px)" : "max(440px, calc(100vh - 440px))"; + const terminalHeight = isOrchestrator ? "clamp(560px, 76vh, 920px)" : "clamp(520px, 72vh, 860px)"; const isOpenCodeSession = session.metadata["agent"] === "opencode"; const opencodeSessionId = typeof session.metadata["opencodeSessionId"] === "string" && @@ -217,233 +307,62 @@ export function SessionDetail({ : undefined; return ( -
- {/* Nav bar — glass effect */} - - - {/* Orchestrator status strip */} +
{isOrchestrator && orchestratorZones && ( - + )} -
- {/* ── Header card ─────────────────────────────────────────── */} -
-
-
-
-

- {session.id} -

- {/* Activity badge */} -
- - - {activity.label} - -
-
- - {session.summary && ( -

- {session.summary} -

- )} - - {/* Meta chips */} -
- {session.projectId && ( - <> - {pr ? ( - - {session.projectId} - - ) : ( - - {session.projectId} - - )} - · - - )} - - {pr && ( - <> - - PR #{pr.number} - - {(session.branch || session.issueUrl) && ( - · - )} - - )} - - {session.branch && ( - <> - {pr ? ( - - {session.branch} - - ) : ( - - {session.branch} - - )} - {session.issueUrl && ( - · - )} - - )} - - {session.issueUrl && ( - - {session.issueLabel || session.issueUrl} - - )} -
- - -
-
-
- - {/* ── PR Card ─────────────────────────────────────────────── */} - {pr && } - - {/* ── Terminal ─────────────────────────────────────────────── */} -
-
-
+
+ {!isOrchestrator && ( + - - Terminal - -
- -
+ )} + +
+
+
+ + Live Terminal + +
+ +
+ + {pr ? ( +
+ +
+ ) : null} +
); } -// ── Client-side timestamps ──────────────────────────────────────────── - -function ClientTimestamps({ - status, - createdAt, - lastActivityAt, -}: { - status: string; - createdAt: string; - lastActivityAt: string; -}) { - const [created, setCreated] = useState(null); - const [lastActive, setLastActive] = useState(null); - - useEffect(() => { - setCreated(relativeTime(createdAt)); - setLastActive(relativeTime(lastActivityAt)); - }, [createdAt, lastActivityAt]); - - return ( -
- - {humanizeStatus(status)} - - {created && ( - <> - · - created {created} - - )} - {lastActive && ( - <> - · - active {lastActive} - - )} -
- ); -} - // ── PR Card ─────────────────────────────────────────────────────────── function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) { @@ -527,7 +446,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) { : "var(--color-border-default)"; return ( -
+
{/* Title row */}
· Merged @@ -567,7 +486,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
{/* Ready-to-merge banner */} {allGreen ? ( -
+
Unresolved Comments {pr.unresolvedThreads} @@ -612,7 +531,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) { const { title, description } = cleanBugbotComment(c.body); return (
- + handleAskAgentToFix(c)} disabled={sendingComments.has(c.url)} className={cn( - "mt-1.5 rounded-[4px] px-3 py-1 text-[11px] font-semibold transition-all", + "mt-1.5 px-3 py-1 text-[11px] font-semibold transition-all", sentComments.has(c.url) ? "bg-[var(--color-status-ready)] text-white" : errorComments.has(c.url) diff --git a/packages/web/src/components/Skeleton.tsx b/packages/web/src/components/Skeleton.tsx new file mode 100644 index 000000000..d61c430a5 --- /dev/null +++ b/packages/web/src/components/Skeleton.tsx @@ -0,0 +1,38 @@ +// ── State UI ────────────────────────────────────────────────────────── + +interface EmptyStateProps { + message?: string; +} + +export function EmptyState({ + message, +}: EmptyStateProps) { + const isDefault = !message; + return ( +
+ {/* Terminal icon */} + + + + +

+ {isDefault ? ( + <> + No sessions running. Start one with{" "} + + ao start + + + ) : ( + message + )} +

+
+ ); +} diff --git a/packages/web/src/components/ThemeToggle.tsx b/packages/web/src/components/ThemeToggle.tsx new file mode 100644 index 000000000..329be6996 --- /dev/null +++ b/packages/web/src/components/ThemeToggle.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + + if (!mounted) return
; + + const isDark = resolvedTheme === "dark"; + + return ( + + ); +} diff --git a/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx b/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx new file mode 100644 index 000000000..6eddff75c --- /dev/null +++ b/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Dashboard } from "../Dashboard"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), + usePathname: () => "/", + useSearchParams: () => new URLSearchParams(), +})); + +beforeEach(() => { + const eventSourceMock = { + onmessage: null, + onerror: null, + close: vi.fn(), + }; + const eventSourceConstructor = vi.fn(() => eventSourceMock as unknown as EventSource); + global.EventSource = Object.assign(eventSourceConstructor, { + CONNECTING: 0, + OPEN: 1, + CLOSED: 2, + }) as unknown as typeof EventSource; + global.fetch = vi.fn(); +}); + +describe("Dashboard empty state", () => { + it("shows empty state when there are no sessions (single-project view)", () => { + render(); + expect(screen.getByText(/No sessions running/i)).toBeInTheDocument(); + }); + + it("does not show empty state when sessions exist", () => { + const { queryByText } = render( + , + ); + expect(queryByText(/No sessions running/i)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/__tests__/Dashboard.globalPause.test.tsx b/packages/web/src/components/__tests__/Dashboard.globalPause.test.tsx index 867771d42..5b46489ea 100644 --- a/packages/web/src/components/__tests__/Dashboard.globalPause.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.globalPause.test.tsx @@ -4,6 +4,12 @@ import { Dashboard } from "@/components/Dashboard"; import type { GlobalPauseState } from "@/lib/types"; import { makeSession } from "@/__tests__/helpers"; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), + usePathname: () => "/", + useSearchParams: () => new URLSearchParams(), +})); + describe("Dashboard globalPause banner", () => { let eventSourceMock: { onmessage: ((event: MessageEvent) => void) | null; diff --git a/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx b/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx index 8039d1cfa..c36ce974a 100644 --- a/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.projectOverview.test.tsx @@ -6,6 +6,7 @@ import { makeSession } from "@/__tests__/helpers"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), usePathname: () => "/", + useSearchParams: () => new URLSearchParams(), })); describe("Dashboard project overview cards", () => { diff --git a/packages/web/src/components/__tests__/Dashboard.renderCadence.test.tsx b/packages/web/src/components/__tests__/Dashboard.renderCadence.test.tsx index 57bdd8431..7ee103c91 100644 --- a/packages/web/src/components/__tests__/Dashboard.renderCadence.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.renderCadence.test.tsx @@ -4,6 +4,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const renderCounts = new Map(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), + usePathname: () => "/", + useSearchParams: () => new URLSearchParams(), +})); + vi.mock("@/components/SessionCard", () => ({ SessionCard: memo(({ session }: { session: { id: string } }) => { renderCounts.set(session.id, (renderCounts.get(session.id) ?? 0) + 1); diff --git a/packages/web/src/components/__tests__/DirectTerminal.test.ts b/packages/web/src/components/__tests__/DirectTerminal.test.ts index 20ff16756..9b3f63027 100644 --- a/packages/web/src/components/__tests__/DirectTerminal.test.ts +++ b/packages/web/src/components/__tests__/DirectTerminal.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildDirectTerminalWsUrl } from "@/components/DirectTerminal"; +import { buildDirectTerminalWsUrl, buildTerminalThemes } from "@/components/DirectTerminal"; describe("buildDirectTerminalWsUrl", () => { it("keeps non-standard port when proxy path override is set", () => { @@ -61,3 +61,79 @@ describe("buildDirectTerminalWsUrl", () => { expect(wsUrl).toBe("ws://localhost:14888/ws?session=session-4"); }); }); + +const HEX_RE = /^#[0-9a-fA-F]{6}$/; +const ANSI_KEYS = [ + "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", + "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite", +] as const; + +function hexToRgb(hex: string): [number, number, number] { + return [ + Number.parseInt(hex.slice(1, 3), 16), + Number.parseInt(hex.slice(3, 5), 16), + Number.parseInt(hex.slice(5, 7), 16), + ]; +} + +function toLinear(channel: number): number { + const normalized = channel / 255; + return normalized <= 0.04045 + ? normalized / 12.92 + : Math.pow((normalized + 0.055) / 1.055, 2.4); +} + +function relativeLuminance(hex: string): number { + const [r, g, b] = hexToRgb(hex); + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); +} + +function contrastRatio(a: string, b: string): number { + const lighter = Math.max(relativeLuminance(a), relativeLuminance(b)); + const darker = Math.min(relativeLuminance(a), relativeLuminance(b)); + return (lighter + 0.05) / (darker + 0.05); +} + +describe("buildTerminalThemes", () => { + it("dark theme has valid hex colors for bg, fg, and all ANSI slots", () => { + const { dark } = buildTerminalThemes("agent"); + expect(dark.background).toMatch(HEX_RE); + expect(dark.foreground).toMatch(HEX_RE); + for (const key of ANSI_KEYS) { + expect(dark[key]).toMatch(HEX_RE); + } + }); + + it("light theme has valid hex colors for bg, fg, and all ANSI slots", () => { + const { light } = buildTerminalThemes("agent"); + expect(light.background).toBe("#fafafa"); + expect(light.foreground).toBe("#24292f"); + for (const key of ANSI_KEYS) { + expect(light[key]).toMatch(HEX_RE); + } + }); + + it("light theme ANSI colors maintain readable contrast on the terminal background", () => { + const { light } = buildTerminalThemes("agent"); + for (const key of ANSI_KEYS) { + expect(contrastRatio(light.background!, light[key]!)).toBeGreaterThanOrEqual(4); + } + }); + + it("dark theme background is #0a0a0f", () => { + const { dark } = buildTerminalThemes("agent"); + expect(dark.background).toBe("#0a0a0f"); + }); + + it("variant changes cursor color between agent and orchestrator", () => { + const agent = buildTerminalThemes("agent"); + const orch = buildTerminalThemes("orchestrator"); + expect(agent.dark.cursor).not.toBe(orch.dark.cursor); + expect(agent.light.cursor).not.toBe(orch.light.cursor); + }); + + it("selection colors differ between dark and light themes", () => { + const { dark, light } = buildTerminalThemes("agent"); + expect(dark.selectionBackground).not.toBe(light.selectionBackground); + }); +}); diff --git a/packages/web/src/components/__tests__/ProjectSidebar.test.tsx b/packages/web/src/components/__tests__/ProjectSidebar.test.tsx index 14a556bc6..73f2b5a0f 100644 --- a/packages/web/src/components/__tests__/ProjectSidebar.test.tsx +++ b/packages/web/src/components/__tests__/ProjectSidebar.test.tsx @@ -23,18 +23,18 @@ describe("ProjectSidebar", () => { it("renders nothing when there is only one project", () => { const { container } = render( - , + , ); expect(container.firstChild).toBeNull(); }); it("renders nothing when there are no projects", () => { - const { container } = render(); + const { container } = render(); expect(container.firstChild).toBeNull(); }); it("renders sidebar with all projects when there are multiple", () => { - render(); + render(); expect(screen.getByText("Projects")).toBeInTheDocument(); expect(screen.getByText("All Projects")).toBeInTheDocument(); expect(screen.getByText("Project One")).toBeInTheDocument(); @@ -43,25 +43,25 @@ describe("ProjectSidebar", () => { }); it("highlights active project", () => { - render(); + render(); const projectTwoButton = screen.getByRole("button", { name: "Project Two" }); expect(projectTwoButton.className).toContain("accent"); }); it("highlights 'All Projects' when no project is active", () => { - render(); + render(); const allProjectsButton = screen.getByRole("button", { name: "All Projects" }); expect(allProjectsButton.className).toContain("accent"); }); it("navigates to project query param when clicking a project", () => { - render(); + render(); fireEvent.click(screen.getByRole("button", { name: "Project Two" })); expect(mockPush).toHaveBeenCalledWith("/?project=project-2"); }); it("navigates to 'all' when clicking 'All Projects'", () => { - render(); + render(); fireEvent.click(screen.getByRole("button", { name: "All Projects" })); expect(mockPush).toHaveBeenCalledWith("/?project=all"); }); @@ -71,7 +71,7 @@ describe("ProjectSidebar", () => { { id: "my-app", name: "My App" }, { id: "other-project", name: "Other Project" }, ]; - render(); + render(); fireEvent.click(screen.getByRole("button", { name: "Other Project" })); expect(mockPush).toHaveBeenCalledWith("/?project=other-project"); }); diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index bec38d08f..db820c221 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -36,7 +36,7 @@ import { } from "@composio/ao-core/types"; // Re-export for use in client components -export { TERMINAL_STATUSES, TERMINAL_ACTIVITIES, NON_RESTORABLE_STATUSES }; +export { CI_STATUS, TERMINAL_STATUSES, TERMINAL_ACTIVITIES, NON_RESTORABLE_STATUSES }; /** * Attention zone priority level, ordered by human action urgency: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3893d7c74..ce6fcd952 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -583,6 +583,9 @@ importers: next: specifier: ^15.1.0 version: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.0.0 version: 19.2.4 @@ -3157,6 +3160,12 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@15.5.12: resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -3638,6 +3647,7 @@ packages: tar@7.5.7: resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -6559,6 +6569,11 @@ snapshots: negotiator@1.0.0: {} + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + next@15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 15.5.12