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:
parent
9ee1660dfa
commit
6b7a8bd20c
|
|
@ -64,3 +64,4 @@ CLAUDE.md
|
|||
AGENTS.md
|
||||
.claude/
|
||||
.opencode/
|
||||
.gstack/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const zoneConfig: Record<
|
|||
}
|
||||
> = {
|
||||
merge: {
|
||||
label: "Merge Ready",
|
||||
label: "Ready",
|
||||
color: "var(--color-status-ready)",
|
||||
caption: "Cleared to land",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)]">·</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)]">·</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)]">·</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">·</span>
|
||||
<span>created {created}</span>
|
||||
</>
|
||||
)}
|
||||
{lastActive && (
|
||||
<>
|
||||
<span className="opacity-40">·</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)]">·</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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue