chore(web): design audit fixes — rename column, refine styles, fix terminal

- Rename "Merge Ready" kanban column to "Ready"
- Refine card design system and globals.css theme tokens
- Fix terminal scrollbar and theme handling in DirectTerminal
- Update SessionDetail layout
- Update tests to match component changes
- Add .gstack/ to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ashish Huddar 2026-03-23 09:58:24 +05:30
parent 9ee1660dfa
commit 6b7a8bd20c
10 changed files with 723 additions and 855 deletions

1
.gitignore vendored
View File

@ -64,3 +64,4 @@ CLAUDE.md
AGENTS.md
.claude/
.opencode/
.gstack/

View File

@ -247,7 +247,7 @@ describe("SessionCard", () => {
});
const session = makeSession({ status: "mergeable", activity: "idle", pr });
render(<SessionCard session={session} />);
expect(screen.getByText("Merge PR")).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(<SessionCard session={session} onMerge={onMerge} />);
fireEvent.click(screen.getByText("Merge PR"));
fireEvent.click(screen.getByRole("button", { name: /merge/i }));
expect(onMerge).toHaveBeenCalledWith(42);
});
@ -415,21 +415,16 @@ describe("SessionCard", () => {
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(<SessionCard session={session} />);
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(<SessionCard session={session} />);
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(<SessionCard session={session} />);
fireEvent.click(container.firstElementChild!);
expect(screen.getByText("terminate")).toBeInTheDocument();
render(<SessionCard session={session} />);
expect(screen.getByRole("button", { name: /terminate session/i })).toBeInTheDocument();
});
});

View File

@ -1,416 +0,0 @@
"use client";
import { AttentionZone } from "@/components/AttentionZone";
import { SessionCard } from "@/components/SessionCard";
import { ThemeToggle } from "@/components/ThemeToggle";
import {
getAttentionLevel,
type AttentionLevel,
type DashboardPR,
type DashboardSession,
} from "@/lib/types";
export const dynamic = "force-dynamic";
function makePR(overrides: Partial<DashboardPR> = {}): DashboardPR {
return {
number: 125,
url: "https://github.com/composio/agent-orchestrator/pull/125",
title: "feat(web): redesign dashboard, session detail, and orchestrator terminal",
owner: "composio",
repo: "agent-orchestrator",
branch: "feat/dashboard-redesign",
baseBranch: "main",
isDraft: false,
state: "open",
additions: 3730,
deletions: 697,
ciStatus: "passing",
ciChecks: [
{ name: "build", status: "passed", url: "https://github.com/composio/agent-orchestrator/actions" },
{ name: "typecheck", status: "passed", url: "https://github.com/composio/agent-orchestrator/actions" },
],
reviewDecision: "approved",
mergeability: {
mergeable: true,
ciPassing: true,
approved: true,
noConflicts: true,
blockers: [],
},
unresolvedThreads: 0,
unresolvedComments: [],
...overrides,
};
}
function makeSession(overrides: Partial<DashboardSession> = {}): DashboardSession {
return {
id: "ao-58",
projectId: "agent-orchestrator",
status: "working",
activity: "idle",
branch: "feat/dashboard-redesign",
issueId: "https://linear.app/composio/issue/AO-557",
issueUrl: "https://linear.app/composio/issue/AO-557",
issueLabel: "#557",
issueTitle: "Dashboard UI: Phase 8 - Multi-Project Sidebar Redesign",
summary: "Dashboard UI: Phase 8 - Multi-Project Sidebar Redesign",
summaryIsFallback: false,
createdAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
pr: null,
metadata: {},
...overrides,
};
}
const noop = () => {};
const showcaseCards: Array<{ title: string; note: string; session: DashboardSession }> = [
{
title: "Working",
note: "Default in-flight work with no PR pressure.",
session: makeSession({
id: "ao-41",
activity: "active",
status: "working",
branch: "feat/parallel-agents",
issueLabel: "#541",
issueTitle: "Improve worker session orchestration",
summary: "Ship runtime orchestration improvements across worker sessions",
}),
},
{
title: "Respond",
note: "Human input required with all ask-to action tags present.",
session: makeSession({
id: "ao-58",
status: "needs_input",
activity: "waiting_input",
branch: "session/ao-58",
pr: makePR({
ciStatus: "failing",
ciChecks: [
{ name: "CI", status: "failed", url: "https://github.com/composio/agent-orchestrator/actions/runs/1" },
],
reviewDecision: "pending",
mergeability: {
mergeable: false,
ciPassing: false,
approved: false,
noConflicts: true,
blockers: ["CI failing", "Review required"],
},
unresolvedThreads: 3,
unresolvedComments: [
{
url: "https://github.com/composio/agent-orchestrator/pull/125#discussion_r1",
path: "packages/web/src/components/Dashboard.tsx",
author: "reviewer",
body: "Resolve before landing",
},
{
url: "https://github.com/composio/agent-orchestrator/pull/125#discussion_r2",
path: "packages/web/src/components/SessionCard.tsx",
author: "reviewer",
body: "Tighten state handling",
},
{
url: "https://github.com/composio/agent-orchestrator/pull/125#discussion_r3",
path: "packages/web/src/app/globals.css",
author: "reviewer",
body: "Spacing polish",
},
],
}),
}),
},
{
title: "Review",
note: "Changes requested state with review pressure.",
session: makeSession({
id: "ao-63",
status: "changes_requested",
activity: "blocked",
branch: "feat/pr-review-flow",
issueLabel: "#563",
issueTitle: "Improve review routing for agent sessions",
summary: "Address reviewer feedback and route sessions back into the queue",
pr: makePR({
number: 131,
url: "https://github.com/composio/agent-orchestrator/pull/131",
additions: 842,
deletions: 119,
ciStatus: "passing",
reviewDecision: "changes_requested",
mergeability: {
mergeable: false,
ciPassing: true,
approved: false,
noConflicts: true,
blockers: ["Changes requested"],
},
}),
}),
},
{
title: "Pending",
note: "Nothing to do yet; waiting on CI and reviewers.",
session: makeSession({
id: "ao-77",
status: "review_pending",
activity: "idle",
branch: "feat/slack-notifier",
issueLabel: "#577",
issueTitle: "Add Slack notification escalation",
pr: makePR({
number: 141,
url: "https://github.com/composio/agent-orchestrator/pull/141",
additions: 201,
deletions: 48,
ciStatus: "pending",
reviewDecision: "pending",
mergeability: {
mergeable: false,
ciPassing: false,
approved: false,
noConflicts: true,
blockers: ["CI pending", "Review pending"],
},
}),
}),
},
{
title: "Merge Ready",
note: "Clean PR with green CI and approval.",
session: makeSession({
id: "ao-88",
status: "approved",
activity: "ready",
branch: "feat/merge-automation",
issueLabel: "#588",
issueTitle: "Automate merge-ready session handling",
pr: makePR({
number: 155,
url: "https://github.com/composio/agent-orchestrator/pull/155",
additions: 129,
deletions: 22,
}),
}),
},
{
title: "Rate Limited",
note: "PR data degraded by GitHub API rate limiting.",
session: makeSession({
id: "ao-92",
status: "working",
activity: "idle",
branch: "feat/github-cache",
issueLabel: "#592",
issueTitle: "Add PR cache fallback for rate limits",
pr: makePR({
number: 161,
url: "https://github.com/composio/agent-orchestrator/pull/161",
ciStatus: "failing",
reviewDecision: "pending",
mergeability: {
mergeable: false,
ciPassing: false,
approved: false,
noConflicts: false,
blockers: ["API rate limited or unavailable"],
},
}),
}),
},
{
title: "Done",
note: "Completed / merged card variant.",
session: makeSession({
id: "ao-99",
status: "merged",
activity: "exited",
branch: "feat/card-showcase",
issueLabel: "#599",
issueTitle: "Create card showcase page",
pr: makePR({
number: 170,
url: "https://github.com/composio/agent-orchestrator/pull/170",
state: "merged",
}),
}),
},
];
export default function CardShowcasePage() {
const boardSessions = showcaseCards.map((entry) => entry.session);
const grouped: Record<AttentionLevel, DashboardSession[]> = {
merge: [],
respond: [],
review: [],
pending: [],
working: [],
done: [],
};
for (const session of boardSessions) {
grouped[getAttentionLevel(session)].push(session);
}
const stats = {
total: boardSessions.length,
active: boardSessions.filter((session) => session.activity && session.activity !== "exited")
.length,
prs: boardSessions.filter((session) => session.pr?.state === "open").length,
review: grouped.review.length,
};
return (
<div className="min-h-screen bg-[var(--color-bg-base)] px-6 py-8 text-[var(--color-text-primary)] md:px-10">
<div className="mx-auto max-w-[1500px]">
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-secondary)]">
Dev Showcase
</div>
<section className="dashboard-hero mb-8">
<div className="dashboard-hero__backdrop" />
<div className="dashboard-hero__content">
<div className="dashboard-hero__primary">
<div className="dashboard-hero__heading">
<div>
<h1 className="dashboard-title">agent-orchestrator</h1>
<p className="dashboard-subtitle">
Static dashboard replica with mocked lane states and card variants for design
review.
</p>
</div>
</div>
<div className="dashboard-stat-cards">
<div className="dashboard-stat-card">
<span className="dashboard-stat-card__value">{stats.total}</span>
<span className="dashboard-stat-card__label">Fleet</span>
</div>
<div className="dashboard-stat-card">
<span
className="dashboard-stat-card__value"
style={{ color: "var(--color-status-working)" }}
>
{stats.active}
</span>
<span className="dashboard-stat-card__label">Active</span>
</div>
<div className="dashboard-stat-card">
<span className="dashboard-stat-card__value">{stats.prs}</span>
<span className="dashboard-stat-card__label">PRs</span>
</div>
<div className="dashboard-stat-card">
<span
className="dashboard-stat-card__value"
style={{ color: "var(--color-status-attention)" }}
>
{stats.review}
</span>
<span className="dashboard-stat-card__label">Review</span>
</div>
</div>
</div>
<div className="dashboard-hero__meta">
<div className="flex items-center gap-3">
<a href="#" className="orchestrator-btn flex items-center gap-2 px-4 py-2 text-[12px] font-semibold hover:no-underline">
<span className="h-1.5 w-1.5 bg-[var(--color-accent)] opacity-80" />
orchestrator
<svg
className="h-3 w-3 opacity-70"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</a>
<ThemeToggle />
</div>
</div>
</div>
</section>
<section className="mb-10">
<div className="board-section-head">
<div>
<h2 className="board-section-head__title">Attention Board</h2>
<p className="board-section-head__subtitle">
Full mocked dashboard board for visual review.
</p>
</div>
<div className="board-section-head__legend">
<span className="board-legend-item">
<span
className="board-legend-item__dot"
style={{ background: "var(--color-status-error)" }}
/>
Human action
</span>
<span className="board-legend-item">
<span
className="board-legend-item__dot"
style={{ background: "var(--color-accent-orange)" }}
/>
Review queue
</span>
<span className="board-legend-item">
<span
className="board-legend-item__dot"
style={{ background: "var(--color-status-ready)" }}
/>
Ready to land
</span>
</div>
</div>
<div className="kanban-board-wrap">
<div className="kanban-board">
<AttentionZone level="working" sessions={grouped.working} onSend={noop} onKill={noop} onMerge={noop} onRestore={noop} />
<AttentionZone level="pending" sessions={grouped.pending} onSend={noop} onKill={noop} onMerge={noop} onRestore={noop} />
<AttentionZone level="review" sessions={grouped.review} onSend={noop} onKill={noop} onMerge={noop} onRestore={noop} />
<AttentionZone level="respond" sessions={grouped.respond} onSend={noop} onKill={noop} onMerge={noop} onRestore={noop} />
<AttentionZone level="merge" sessions={grouped.merge} onSend={noop} onKill={noop} onMerge={noop} onRestore={noop} />
</div>
</div>
</section>
<section className="mb-10">
<div className="mb-4 text-[12px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-secondary)]">
Card Library
</div>
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{showcaseCards.map(({ title, note, session }) => (
<div
key={session.id}
className="border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4"
>
<div className="mb-3">
<h2 className="text-[18px] font-semibold tracking-[-0.03em]">{title}</h2>
<p className="mt-1 text-[12px] leading-5 text-[var(--color-text-secondary)]">
{note}
</p>
</div>
<SessionCard
session={session}
onSend={(_sessionId, message) => {
console.log("showcase send", message);
}}
onKill={noop}
onMerge={noop}
onRestore={noop}
/>
</div>
))}
</div>
</section>
</div>
</div>
);
}

View File

@ -108,9 +108,9 @@
--card-merge-bg: #f6fbf8;
--card-expanded-bg: #f7f7f8;
--card-shadow: none;
--card-shadow-hover: 0 1px 3px rgba(0, 0, 0, 0.06);
--card-shadow-hover: 0 2px 6px rgba(0, 0, 0, 0.08);
--card-inset: none;
--card-border: var(--color-border-subtle);
--card-border: #d5d7de;
/* Done card surfaces */
--card-done-bg: #fafafa;
@ -140,7 +140,7 @@
--btn-inset: none;
/* Kanban column */
--color-column-bg: rgba(0, 0, 0, 0.02);
--color-column-bg: #f4f5f7;
--color-column-header: transparent;
/* Alert colors */
@ -250,6 +250,7 @@
--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%);
@ -279,7 +280,7 @@
--btn-inset: inset 0 1px 0 rgba(255, 255, 255, 0.08);
/* Kanban column */
--color-column-bg: rgba(255, 255, 255, 0.03);
--color-column-bg: #0d1218;
--color-column-header: transparent;
/* Alert colors — Linear-muted */
@ -460,7 +461,7 @@ a:hover {
/* Dark — invisible by default, visible on hover */
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.0);
background: rgba(255, 255, 255, 0);
border-radius: 2.5px;
transition: background 0.3s ease;
}
@ -477,7 +478,7 @@ a:hover {
/* Light — same pattern, darker thumb */
html.light .xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.0);
background: rgba(0, 0, 0, 0);
transition: background 0.3s ease;
}
html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb,
@ -488,7 +489,7 @@ 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.40);
background: rgba(0, 0, 0, 0.4);
}
/* ── Animations ──────────────────────────────────────────────────────── */
@ -542,6 +543,46 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
}
}
@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 {
@ -600,51 +641,22 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 360px;
height: 140px;
pointer-events: none;
background:
radial-gradient(
circle at 8% 4%,
color-mix(in srgb, var(--color-accent) 12%, transparent) 0%,
transparent 26%
),
linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, transparent 100%);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015) 0%, transparent 100%);
}
.dashboard-hero {
position: relative;
overflow: hidden;
overflow: visible;
border: 1px solid var(--color-border-default);
border-radius: 0;
background:
radial-gradient(
circle at top left,
color-mix(in srgb, var(--color-accent) 16%, transparent) 0%,
transparent 36%
),
radial-gradient(
circle at 88% 18%,
color-mix(in srgb, var(--color-status-working) 10%, transparent) 0%,
transparent 24%
),
linear-gradient(
180deg,
color-mix(in srgb, var(--color-bg-elevated) 86%, black 14%) 0%,
color-mix(in srgb, var(--color-bg-surface) 96%, black 4%) 100%
);
box-shadow: 0 24px 60px rgba(2, 6, 12, 0.12);
background: var(--color-bg-surface);
box-shadow: 0 10px 24px rgba(2, 6, 12, 0.06);
}
.dashboard-hero__backdrop {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.6;
background-image:
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.85), transparent);
display: none;
}
.dashboard-hero__content {
@ -653,15 +665,16 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 18px 22px;
gap: 14px;
padding: 14px 18px;
}
.dashboard-hero__primary {
display: flex;
flex: 1 1 720px;
flex-direction: column;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.dashboard-hero__heading {
@ -669,6 +682,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
flex-direction: column;
gap: 8px;
max-width: 760px;
min-width: 0;
}
.dashboard-eyebrow {
@ -695,17 +709,17 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
}
.dashboard-title {
font-size: clamp(24px, 3.2vw, 38px);
line-height: 0.98;
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: 4px;
margin-top: 3px;
max-width: 56ch;
font-size: 12px;
font-size: 11px;
line-height: 1.4;
color: var(--color-text-muted);
}
@ -713,37 +727,34 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.dashboard-hero__meta {
display: flex;
flex: 0 0 auto;
flex-direction: column;
flex-direction: row;
align-items: center;
gap: 12px;
gap: 10px;
margin-left: auto;
}
.dashboard-stat-cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
display: flex;
flex-wrap: wrap;
flex-shrink: 0;
align-items: center;
gap: 8px;
max-width: 720px;
max-width: none;
margin-left: auto;
}
.dashboard-stat-card {
display: flex;
min-height: 64px;
flex-direction: column;
justify-content: center;
gap: 1px;
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:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-bg-base) 25%, transparent) 0%,
color-mix(in srgb, var(--color-bg-subtle) 80%, transparent) 100%
);
padding: 9px 11px 8px;
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 6%, transparent),
0 8px 18px rgba(0, 0, 0, 0.05);
background: color-mix(in srgb, var(--color-bg-base) 72%, transparent);
padding: 6px 9px;
box-shadow: none;
}
.dashboard-stat-card--empty {
@ -751,7 +762,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
}
.dashboard-stat-card__value {
font-size: clamp(18px, 2vw, 24px);
font-size: 18px;
line-height: 1;
font-weight: 600;
letter-spacing: -0.05em;
@ -759,9 +770,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
}
.dashboard-stat-card__label {
font-size: 9px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
@ -781,6 +792,117 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.detail-card {
background: var(--detail-card-bg);
box-shadow: var(--detail-card-shadow);
border-radius: 0;
}
.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 {
@ -793,11 +915,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.session-card {
background: var(--card-bg);
border-color: var(--color-border-subtle);
border-color: var(--card-border);
border-radius: 0;
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 4%, transparent),
0 12px 26px rgba(0, 0, 0, 0.06);
0 10px 20px rgba(0, 0, 0, 0.08);
transition:
transform 0.12s ease,
border-color 0.12s ease,
@ -832,6 +954,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
--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
@ -916,39 +1041,159 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.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) 40%, transparent);
background: color-mix(in srgb, var(--color-status-ready) 8%, transparent);
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: color-mix(in srgb, var(--color-status-ready) 14%, transparent);
text-decoration: underline;
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 {
position: relative;
background: var(--card-merge-bg);
border-color: color-mix(in srgb, var(--color-status-ready) 24%, var(--color-border-default));
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:
inset 0 1px 0 color-mix(in srgb, white 4%, transparent),
0 18px 34px color-mix(in srgb, var(--color-status-ready) 10%, 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 {
border-color: color-mix(in srgb, var(--color-status-ready) 34%, var(--color-border-strong));
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--color-status-ready) 42%, var(--color-border-strong));
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 6%, transparent),
0 22px 40px color-mix(in srgb, var(--color-status-ready) 12%, 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 ─────────────────────────────── */
@ -1329,26 +1574,55 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
}
.project-sidebar__collapsed-project {
position: relative;
display: inline-flex;
width: 30px;
height: 30px;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background: transparent;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: var(--color-bg-surface);
cursor: pointer;
transition:
border-color 0.12s ease,
background 0.12s ease;
border-color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;
}
.project-sidebar__collapsed-project:hover {
border-color: var(--color-border-subtle);
background: var(--color-hover-overlay);
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) 22%, var(--color-border-subtle));
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
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 ────────────────────────────────────────────────────── */
@ -1419,18 +1693,36 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
max-width: 420px;
display: flex;
flex-direction: column;
border: 1px solid var(--color-border-subtle);
border: 1px solid var(--color-border-default);
border-radius: 0;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-bg-elevated) 92%, transparent) 0%,
color-mix(in srgb, var(--color-bg-surface) 96%, transparent) 100%
);
background: var(--color-column-bg);
padding: 4px;
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 3%, transparent),
0 24px 50px rgba(0, 0, 0, 0.06);
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 {
@ -1525,17 +1817,23 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
@media (max-width: 960px) {
.dashboard-hero__content {
padding: 16px 18px;
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 {
grid-template-columns: repeat(2, minmax(0, 1fr));
max-width: none;
width: 100%;
}
.kanban-board {
@ -1558,11 +1856,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
@media (max-width: 640px) {
.dashboard-title {
font-size: 32px;
font-size: 26px;
}
.dashboard-stat-cards {
grid-template-columns: minmax(0, 1fr);
gap: 6px;
}
.kanban-column {

View File

@ -22,7 +22,7 @@ const zoneConfig: Record<
}
> = {
merge: {
label: "Merge Ready",
label: "Ready",
color: "var(--color-status-ready)",
caption: "Cleared to land",
},

View File

@ -9,8 +9,7 @@ import { cn } from "@/lib/cn";
import "xterm/css/xterm.css";
// Dynamically import xterm types for TypeScript
import type { Terminal as TerminalType } from "xterm";
import type { ITheme } from "xterm";
import type { ITheme, Terminal as TerminalType } from "xterm";
import type { FitAddon as FitAddonType } from "@xterm/addon-fit";
interface DirectTerminalProps {
@ -42,8 +41,16 @@ interface DirectTerminalWsUrlOptions {
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 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 = {
@ -74,28 +81,28 @@ export function buildTerminalThemes(variant: TerminalVariant): { dark: ITheme; l
const light: ITheme = {
background: "#fafafa",
foreground: "#383a42",
foreground: "#24292f",
cursor: accent.cursor,
cursorAccent: "#fafafa",
selectionBackground: accent.selLight,
selectionInactiveBackground: "rgba(128, 128, 128, 0.15)",
// ANSI colors — One Light inspired, tuned for #fafafa background
black: "#383a42",
red: "#e45649",
green: "#50a14f",
yellow: "#c18401",
blue: "#4078f2",
magenta: "#a626a4",
cyan: "#0184bc",
white: "#a0a1a7",
brightBlack: "#696c77",
brightRed: "#ca1243",
brightGreen: "#2da44e",
brightYellow: "#986801",
brightBlue: "#4078f2",
brightMagenta: "#a626a4",
brightCyan: "#0070a8",
brightWhite: "#fafafa",
// 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 };
@ -242,8 +249,12 @@ export function DirectTerminal({
const terminal = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: 'var(--font-jetbrains-mono), "JetBrains Mono", "SF Mono", Menlo, Monaco, "Courier New", monospace',
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",
@ -504,6 +515,7 @@ export function DirectTerminal({
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
@ -620,7 +632,7 @@ export function DirectTerminal({
return (
<div
className={cn(
"overflow-hidden rounded-[6px] border border-[var(--color-border-default)]",
"overflow-hidden border border-[var(--color-border-default)]",
resolvedTheme === "light" ? "bg-[#fafafa]" : "bg-[#0a0a0f]",
fullscreen && "fixed inset-0 z-50 rounded-none border-0",
)}
@ -638,7 +650,7 @@ export function DirectTerminal({
</span>
{/* XDA clipboard badge */}
<span
className="rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.06em]"
className="px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.06em]"
style={{
color: accentColor,
background: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
@ -652,7 +664,7 @@ export function DirectTerminal({
disabled={reloading || status !== "connected"}
title="Restart OpenCode session (/exit then resume mapped session)"
aria-label="Restart OpenCode session"
className="ml-auto flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-70"
className="ml-auto flex items-center gap-1 px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-70"
>
{reloading ? (
<>
@ -695,7 +707,7 @@ export function DirectTerminal({
<button
onClick={() => setFullscreen(!fullscreen)}
className={cn(
"flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)]",
"flex items-center gap-1 px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-text-primary)]",
!isOpenCodeSession && "ml-auto",
)}
>

View File

@ -157,11 +157,12 @@ function ProjectSidebarInner({
if (collapsed) {
return (
<aside className="project-sidebar project-sidebar--collapsed flex h-full w-[56px] flex-col items-center py-3">
<div className="flex flex-1 flex-col items-center gap-3">
<div className="flex flex-1 flex-col items-center gap-2">
{projects.map((project) => {
const entry = sessionsByProject.map.get(project.id);
const health = entry ? computeProjectHealth(entry.all) : ("gray" as ProjectHealth);
const isActive = activeProjectId === project.id;
const initial = project.name.charAt(0).toUpperCase();
return (
<button
key={project.id}
@ -173,7 +174,16 @@ function ProjectSidebarInner({
)}
title={project.name}
>
<HealthDot health={health} />
<span className="project-sidebar__avatar">{initial}</span>
{health !== "gray" && (
<span
className={cn(
"project-sidebar__health-indicator",
health === "red" && "animate-[activity-pulse_2s_ease-in-out_infinite]",
)}
style={{ background: healthDotColor[health] }}
/>
)}
</button>
);
})}

View File

@ -550,7 +550,11 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M5 12h14M12 5l7 7-7 7" />
<circle cx="6" cy="6" r="2" />
<circle cx="18" cy="18" r="2" />
<circle cx="18" cy="6" r="2" />
<path d="M8 6h5a3 3 0 0 1 3 3v7" />
<path d="M8 6h5a3 3 0 0 0 3-3V8" />
</svg>
merge
</button>
@ -560,7 +564,8 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
e.stopPropagation();
onKill?.(session.id);
}}
className="session-card__control session-card__terminate inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 border border-[color-mix(in_srgb,var(--color-status-error)_35%,transparent)] px-2.5 py-1 text-[11px] text-[var(--color-status-error)] transition-colors hover:border-[var(--color-status-error)] hover:bg-[var(--color-tint-red)] hover:underline"
aria-label="Terminate session"
className="session-card__control session-card__terminate inline-flex shrink-0 cursor-pointer items-center justify-center border border-[color-mix(in_srgb,var(--color-status-error)_35%,transparent)] px-2.5 py-1 text-[11px] text-[var(--color-status-error)] transition-colors hover:border-[var(--color-status-error)] hover:bg-[var(--color-tint-red)]"
>
<svg
className="session-card__control-icon"
@ -569,9 +574,11 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 6 6 18M6 6l12 12" />
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
terminate
</button>
)}
</div>

View File

@ -1,6 +1,6 @@
"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";
@ -43,18 +43,8 @@ function humanizeStatus(status: string): string {
.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 +65,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 (
<section className="session-page-header">
<div className="session-page-header__crumbs">
<a
href="/"
className="flex items-center gap-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] hover:no-underline"
>
<svg
className="h-3 w-3 opacity-60"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
>
<path d="M15 18l-6-6 6-6" />
</svg>
Orchestrator
</a>
<span className="text-[var(--color-border-strong)]">/</span>
<span className="font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{headline}
</span>
{isOrchestrator ? <span className="session-page-header__mode">orchestrator</span> : null}
</div>
<div className="session-page-header__main">
<div className="session-page-header__identity">
<h1 className="truncate text-[17px] font-semibold tracking-[-0.03em] text-[var(--color-text-primary)]">
{headline}
</h1>
<div className="session-page-header__meta">
<div
className="flex items-center gap-1.5 border px-2.5 py-1"
style={{
background: `color-mix(in srgb, ${activityColor} 12%, transparent)`,
border: `1px solid color-mix(in srgb, ${activityColor} 20%, transparent)`,
}}
>
<span className="h-1.5 w-1.5 shrink-0" style={{ background: activityColor }} />
<span className="text-[11px] font-semibold" style={{ color: activityColor }}>
{activityLabel}
</span>
</div>
{branch ? (
pr ? (
<a
href={buildGitHubBranchUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="session-detail-link-pill font-[var(--font-mono)] text-[10px] hover:no-underline"
>
{branch}
</a>
) : (
<span className="session-detail-link-pill font-[var(--font-mono)] text-[10px]">
{branch}
</span>
)
) : null}
{pr ? (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="session-detail-link-pill session-detail-link-pill--accent hover:no-underline"
>
PR #{pr.number}
</a>
) : null}
</div>
</div>
{rightSlot ? <div className="session-page-header__side">{rightSlot}</div> : null}
</div>
</section>
);
}
async function askAgentToFix(
@ -106,9 +185,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<string>("");
@ -137,52 +226,61 @@ function OrchestratorStatusStrip({
zones.merge + zones.respond + zones.review + zones.working + zones.pending + zones.done;
return (
<div
className="border-b border-[var(--color-border-subtle)] px-8 py-4"
style={{
background: "linear-gradient(to bottom, rgba(88,166,255,0.04) 0%, transparent 100%)",
}}
>
<div className="mx-auto flex max-w-[900px] items-center gap-3 flex-wrap">
{/* Total count */}
<div className="flex items-baseline gap-1.5 mr-2">
<span className="text-[22px] font-bold leading-none tabular-nums text-[var(--color-text-primary)]">
{total}
</span>
<span className="text-[11px] text-[var(--color-text-tertiary)]">agents</span>
</div>
<div className="h-5 w-px bg-[var(--color-border-subtle)] mr-1" />
{/* Per-zone pills */}
{stats.length > 0 ? (
stats.map((s) => (
<div
key={s.label}
className="flex items-center gap-1.5 rounded-full px-2.5 py-1"
style={{ background: s.bg }}
>
<span
className="text-[15px] font-bold leading-none tabular-nums"
style={{ color: s.color }}
>
{s.value}
</span>
<span className="text-[10px] font-medium" style={{ color: s.color, opacity: 0.8 }}>
{s.label}
<div className="mx-auto max-w-[1180px] px-5 pt-5 lg:px-8">
<SessionTopStrip
headline={headline}
activityLabel={activityLabel}
activityColor={activityColor}
branch={branch}
pr={pr}
isOrchestrator
rightSlot={
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
<div className="flex items-baseline gap-1.5 mr-2">
<span className="text-[22px] font-bold leading-none tabular-nums text-[var(--color-text-primary)]">
{total}
</span>
<span className="text-[11px] text-[var(--color-text-tertiary)]">agents</span>
</div>
))
) : (
<span className="text-[12px] text-[var(--color-text-tertiary)]">no active agents</span>
)}
{uptime && (
<span className="ml-auto font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
up {uptime}
</span>
)}
</div>
<div className="h-5 w-px bg-[var(--color-border-subtle)] mr-1" />
{/* Per-zone pills */}
{stats.length > 0 ? (
stats.map((s) => (
<div
key={s.label}
className="flex items-center gap-1.5 px-2.5 py-1"
style={{ background: s.bg }}
>
<span
className="text-[15px] font-bold leading-none tabular-nums"
style={{ color: s.color }}
>
{s.value}
</span>
<span
className="text-[10px] font-medium"
style={{ color: s.color, opacity: 0.8 }}
>
{s.label}
</span>
</div>
))
) : (
<span className="text-[12px] text-[var(--color-text-tertiary)]">
no active agents
</span>
)}
{uptime && (
<span className="ml-auto font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
up {uptime}
</span>
)}
</div>
}
/>
</div>
);
}
@ -201,11 +299,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 +316,62 @@ export function SessionDetail({
: undefined;
return (
<div className="min-h-screen bg-[var(--color-bg-base)]">
{/* Nav bar — glass effect */}
<nav className="nav-glass sticky top-0 z-10 border-b border-[var(--color-border-subtle)]">
<div className="mx-auto flex max-w-[900px] items-center gap-2 px-8 py-2.5">
<a
href="/"
className="flex items-center gap-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] hover:no-underline"
>
<svg
className="h-3 w-3 opacity-60"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
>
<path d="M15 18l-6-6 6-6" />
</svg>
Orchestrator
</a>
<span className="text-[var(--color-border-strong)]">/</span>
<span className="font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{session.id}
</span>
{isOrchestrator && (
<span
className="ml-1 rounded px-2 py-0.5 text-[10px] font-semibold tracking-[0.05em]"
style={{
color: accentColor,
background: `color-mix(in srgb, ${accentColor} 10%, transparent)`,
border: `1px solid color-mix(in srgb, ${accentColor} 20%, transparent)`,
}}
>
orchestrator
</span>
)}
</div>
</nav>
{/* Orchestrator status strip */}
<div className="session-detail-page min-h-screen bg-[var(--color-bg-base)]">
{isOrchestrator && orchestratorZones && (
<OrchestratorStatusStrip zones={orchestratorZones} createdAt={session.createdAt} />
<OrchestratorStatusStrip
zones={orchestratorZones}
createdAt={session.createdAt}
headline={headline}
activityLabel={activity.label}
activityColor={activity.color}
branch={session.branch}
pr={pr}
/>
)}
<div className="mx-auto max-w-[900px] px-8 py-6">
{/* ── Header card ─────────────────────────────────────────── */}
<div
className="detail-card mb-6 rounded-[8px] border border-[var(--color-border-default)] p-5"
style={{
borderLeft: isOrchestrator ? `3px solid ${accentColor}` : `3px solid ${activity.color}`,
}}
>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
<h1 className="font-[var(--font-mono)] text-[17px] font-semibold tracking-[-0.01em] text-[var(--color-text-primary)]">
{session.id}
</h1>
{/* Activity badge */}
<div
className="flex items-center gap-1.5 rounded-full px-2.5 py-0.5"
style={{
background: `color-mix(in srgb, ${activity.color} 12%, transparent)`,
border: `1px solid color-mix(in srgb, ${activity.color} 20%, transparent)`,
}}
>
<ActivityDot activity={session.activity} dotOnly size={6} />
<span className="text-[11px] font-semibold" style={{ color: activity.color }}>
{activity.label}
</span>
</div>
</div>
{session.summary && (
<p className="mt-2 text-[13px] leading-relaxed text-[var(--color-text-secondary)]">
{session.summary}
</p>
)}
{/* Meta chips */}
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{session.projectId && (
<>
{pr ? (
<a
href={buildGitHubRepoUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="rounded-[4px] border border-[var(--color-border-subtle)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-strong)] hover:text-[var(--color-text-primary)] hover:no-underline"
>
{session.projectId}
</a>
) : (
<span className="rounded-[4px] border border-[var(--color-border-subtle)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[11px] text-[var(--color-text-secondary)]">
{session.projectId}
</span>
)}
<span className="text-[var(--color-text-tertiary)]">&middot;</span>
</>
)}
{pr && (
<>
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-[4px] border border-[var(--color-border-subtle)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:border-[var(--color-accent)] hover:no-underline"
>
PR #{pr.number}
</a>
{(session.branch || session.issueUrl) && (
<span className="text-[var(--color-text-tertiary)]">&middot;</span>
)}
</>
)}
{session.branch && (
<>
{pr ? (
<a
href={buildGitHubBranchUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="rounded-[4px] border border-[var(--color-border-subtle)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 font-[var(--font-mono)] text-[10px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-strong)] hover:text-[var(--color-text-primary)] hover:no-underline"
>
{session.branch}
</a>
) : (
<span className="rounded-[4px] border border-[var(--color-border-subtle)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 font-[var(--font-mono)] text-[10px] text-[var(--color-text-secondary)]">
{session.branch}
</span>
)}
{session.issueUrl && (
<span className="text-[var(--color-text-tertiary)]">&middot;</span>
)}
</>
)}
{session.issueUrl && (
<a
href={session.issueUrl}
target="_blank"
rel="noopener noreferrer"
className="rounded-[4px] border border-[var(--color-border-subtle)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-strong)] hover:text-[var(--color-text-primary)] hover:no-underline"
>
{session.issueLabel || session.issueUrl}
</a>
)}
</div>
<ClientTimestamps
status={session.status}
createdAt={session.createdAt}
lastActivityAt={session.lastActivityAt}
/>
</div>
</div>
</div>
{/* ── PR Card ─────────────────────────────────────────────── */}
{pr && <PRCard pr={pr} sessionId={session.id} />}
{/* ── Terminal ─────────────────────────────────────────────── */}
<div className={pr ? "mt-6" : ""}>
<div className="mb-3 flex items-center gap-2">
<div
className="h-3 w-0.5 rounded-full"
style={{ background: isOrchestrator ? accentColor : activity.color, opacity: 0.7 }}
<div className="mx-auto max-w-[1180px] px-5 py-5 lg:px-8">
<main className="min-w-0">
{!isOrchestrator && (
<SessionTopStrip
headline={headline}
activityLabel={activity.label}
activityColor={activity.color}
branch={session.branch}
pr={pr}
/>
<span className="text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
Terminal
</span>
</div>
<DirectTerminal
sessionId={session.id}
startFullscreen={startFullscreen}
variant={terminalVariant}
height={terminalHeight}
isOpenCodeSession={isOpenCodeSession}
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
/>
</div>
)}
<section className="mt-5">
<div className="mb-3 flex items-center gap-2">
<div
className="h-3 w-0.5"
style={{ background: isOrchestrator ? accentColor : activity.color, opacity: 0.75 }}
/>
<span className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]">
Live Terminal
</span>
</div>
<DirectTerminal
sessionId={session.id}
startFullscreen={startFullscreen}
variant={terminalVariant}
height={terminalHeight}
isOpenCodeSession={isOpenCodeSession}
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
/>
</section>
{pr ? (
<section className="mt-6">
<PRCard pr={pr} sessionId={session.id} />
</section>
) : null}
</main>
</div>
</div>
);
}
// ── Client-side timestamps ────────────────────────────────────────────
function ClientTimestamps({
status,
createdAt,
lastActivityAt,
}: {
status: string;
createdAt: string;
lastActivityAt: string;
}) {
const [created, setCreated] = useState<string | null>(null);
const [lastActive, setLastActive] = useState<string | null>(null);
useEffect(() => {
setCreated(relativeTime(createdAt));
setLastActive(relativeTime(lastActivityAt));
}, [createdAt, lastActivityAt]);
return (
<div className="mt-2.5 flex flex-wrap items-center gap-x-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<span className="rounded-[3px] bg-[rgba(255,255,255,0.05)] px-1.5 py-0.5 text-[10px] font-medium">
{humanizeStatus(status)}
</span>
{created && (
<>
<span className="opacity-40">&middot;</span>
<span>created {created}</span>
</>
)}
{lastActive && (
<>
<span className="opacity-40">&middot;</span>
<span>active {lastActive}</span>
</>
)}
</div>
);
}
// ── PR Card ───────────────────────────────────────────────────────────
function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
@ -527,7 +455,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
: "var(--color-border-default)";
return (
<div className="detail-card mb-6 overflow-hidden rounded-[8px] border" style={{ borderColor }}>
<div className="detail-card mb-6 overflow-hidden border" style={{ borderColor }}>
{/* Title row */}
<div className="border-b border-[var(--color-border-subtle)] px-5 py-3.5">
<a
@ -553,7 +481,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
<>
<span className="text-[var(--color-text-tertiary)]">&middot;</span>
<span
className="rounded-full px-2 py-0.5 text-[10px] font-semibold"
className="px-2 py-0.5 text-[10px] font-semibold"
style={{ color: "#a371f7", background: "rgba(163,113,247,0.12)" }}
>
Merged
@ -567,7 +495,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
<div className="px-5 py-4">
{/* Ready-to-merge banner */}
{allGreen ? (
<div className="flex items-center gap-2 rounded-[5px] border border-[rgba(63,185,80,0.25)] bg-[rgba(63,185,80,0.07)] px-3.5 py-2.5">
<div className="flex items-center gap-2 border border-[rgba(63,185,80,0.25)] bg-[rgba(63,185,80,0.07)] px-3.5 py-2.5">
<svg
className="h-4 w-4 shrink-0 text-[var(--color-status-ready)]"
fill="none"
@ -601,7 +529,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
<h4 className="mb-2.5 flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
Unresolved Comments
<span
className="rounded-full px-1.5 py-0.5 text-[10px] font-bold normal-case tracking-normal"
className="px-1.5 py-0.5 text-[10px] font-bold normal-case tracking-normal"
style={{ color: "#f85149", background: "rgba(248,81,73,0.12)" }}
>
{pr.unresolvedThreads}
@ -612,7 +540,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
const { title, description } = cleanBugbotComment(c.body);
return (
<details key={c.url} className="group">
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] transition-colors hover:bg-[rgba(255,255,255,0.04)]">
<summary className="flex cursor-pointer list-none items-center gap-2 px-2 py-1.5 text-[12px] transition-colors hover:bg-[rgba(255,255,255,0.04)]">
<svg
className="h-3 w-3 shrink-0 text-[var(--color-text-tertiary)] transition-transform group-open:rotate-90"
fill="none"
@ -647,7 +575,7 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
onClick={() => 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)

View File

@ -68,6 +68,32 @@ const ANSI_KEYS = [
"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");
@ -81,12 +107,19 @@ describe("buildTerminalThemes", () => {
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("#383a42");
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");