Fix root dashboard project selection (#1480)

* style(design): strengthen dashboard hierarchy

* fix(web): prefer current repo dashboard project

* Revert "style(design): strengthen dashboard hierarchy"

This reverts commit 5db28280e1.

* Fix root dashboard project selection

* fix(web): bound session loading and reduce dashboard polling

* Fix project-name test temp path and reuse loaded config

* fix(web): clear recovered errors and restore fresh detail polling

* fix: stabilize dashboard session handling

* Fix ambiguous project-name fallback discovery

* Fix dashboard session selection regressions

* Remove unused orchestrator session imports
This commit is contained in:
Ashish Huddar 2026-04-24 18:35:38 +05:30 committed by GitHub
parent b086908f60
commit 0538e07b65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 3297 additions and 1153 deletions

View File

@ -1507,10 +1507,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
throw new Error(`Agent plugin '${selection.agentName}' not found`);
}
const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy(
project.orchestratorSessionStrategy,
);
// Get the sessions directory for this project
const sessionsDir = getProjectSessionsDir(project);
@ -1520,7 +1516,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
validateAndStoreOrigin(config.configPath, project.storageKey!);
}
// Reserve a new unique orchestrator identity (e.g. {prefix}-orchestrator-1, -2, …).
const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy(
project.orchestratorSessionStrategy,
);
// Reserve a new unique orchestrator identity (e.g. {prefix}-orchestrator-1, -2, ...).
// Each spawnOrchestrator call gets its own numbered session and isolated worktree.
const identity = reserveNextOrchestratorIdentity(project, sessionsDir);
const sessionId = identity.sessionId;

View File

@ -211,6 +211,7 @@ vi.mock("@/lib/services", () => ({
// ── Import routes after mocking ───────────────────────────────────────
import { GET as sessionsGET } from "@/app/api/sessions/route";
import { GET as sessionDetailGET } from "@/app/api/sessions/[id]/route";
import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route";
import { POST as spawnPOST } from "@/app/api/spawn/route";
import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route";
@ -683,6 +684,32 @@ describe("API Routes", () => {
});
});
describe("GET /api/sessions/[id]", () => {
it("returns partial session data when metadata and PR enrichment stall", async () => {
const metadataSpy = vi
.spyOn(serialize, "enrichSessionsMetadata")
.mockImplementation(() => new Promise<void>(() => {}));
const prSpy = vi
.spyOn(serialize, "enrichSessionPR")
.mockImplementation(() => new Promise<boolean>(() => {}));
const responsePromise = sessionDetailGET(
makeRequest("http://localhost:3000/api/sessions/backend-7"),
{ params: Promise.resolve({ id: "backend-7" }) },
);
const res = await responsePromise;
const data = await res.json();
expect(res.status).toBe(200);
expect(data.id).toBe("backend-7");
expect(data.projectId).toBe("my-app");
metadataSpy.mockRestore();
prSpy.mockRestore();
}, 10_000);
});
describe("GET /api/runtime/terminal", () => {
function withEnv(overrides: Record<string, string | undefined>, fn: () => Promise<void>) {
const saved: Record<string, string | undefined> = {};

View File

@ -204,18 +204,71 @@ describe("SessionCard", () => {
expect(screen.getByText("feat/cool-thing")).toBeInTheDocument();
});
it("does not render lifecycle guidance as a pill on kanban cards", () => {
const session = makeSession({
lifecycle: {
sessionState: "detecting",
sessionReason: "runtime_lost",
prState: "none",
prReason: "not_created",
runtimeState: "missing",
runtimeReason: "tmux_missing",
session: {
state: "detecting",
reason: "runtime_lost",
label: "detecting",
reasonLabel: "runtime lost",
startedAt: new Date().toISOString(),
completedAt: null,
terminatedAt: null,
lastTransitionAt: new Date().toISOString(),
},
pr: {
state: "none",
reason: "not_created",
label: "not created",
reasonLabel: "not created",
number: null,
url: null,
lastObservedAt: null,
},
runtime: {
state: "missing",
reason: "tmux_missing",
label: "missing",
reasonLabel: "tmux missing",
lastObservedAt: new Date().toISOString(),
},
legacyStatus: "detecting",
evidence: null,
detectingAttempts: 1,
detectingEscalatedAt: null,
summary: "Detecting runtime truth (runtime lost)",
guidance: "Checking runtime and process evidence now.",
},
});
render(<SessionCard session={session} />);
expect(
screen.queryByText("Checking runtime and process evidence now."),
).not.toBeInTheDocument();
});
it("renders terminal link", () => {
const session = makeSession({ id: "backend-5" });
render(<SessionCard session={session} />);
const link = screen.getByText("terminal");
expect(link).toHaveAttribute("href", "/projects/my-app/sessions/backend-5#session-terminal-section");
expect(link).toHaveAttribute(
"href",
"/projects/my-app/sessions/backend-5#session-terminal-section",
);
});
it("shows restore button when agent has exited", () => {
const session = makeSession({ activity: "exited" });
render(<SessionCard session={session} />);
// Header shows compact "restore"; expanded panel shows "restore session"
expect(screen.getByText("restore")).toBeInTheDocument();
expect(screen.getByText("restore")).toHaveClass("session-card__restore-control");
});
it("does not show restore button when agent is active", () => {
@ -274,7 +327,11 @@ describe("SessionCard", () => {
state: "open",
ciStatus: "passing",
ciChecks: [
{ name: "lint-and-type-checks", status: "passed", url: "https://github.com/owner/repo/runs/111" },
{
name: "lint-and-type-checks",
status: "passed",
url: "https://github.com/owner/repo/runs/111",
},
{ name: "tests", status: "passed", url: "https://github.com/owner/repo/runs/222" },
{ name: "no-url-check", status: "passed" },
],
@ -524,6 +581,29 @@ describe("SessionCard", () => {
});
});
it("hides quick reply presets for terminal sessions", () => {
const session = makeSession({
id: "respond-ended",
status: "terminated",
activity: "exited",
summary: "Need approval to proceed",
});
render(<SessionCard session={session} />);
expect(screen.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Abort" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Skip" })).not.toBeInTheDocument();
expect(
screen.queryByRole("textbox", { name: /type a reply to the agent/i }),
).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: /view current context/i })).toHaveAttribute(
"href",
"/projects/my-app/sessions/respond-ended",
);
expect(screen.getByText("restore")).toBeInTheDocument();
});
it("prevents duplicate enter submits and only clears the textarea after send settles", async () => {
let resolveSend: (() => void) | null = null;
const onSend = vi.fn(
@ -587,7 +667,9 @@ describe("SessionCard", () => {
expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).toHaveValue(
"please continue",
);
expect(screen.getByRole("textbox", { name: /type a reply to the agent/i })).not.toBeDisabled();
expect(
screen.getByRole("textbox", { name: /type a reply to the agent/i }),
).not.toBeDisabled();
});
});

View File

@ -10,7 +10,7 @@ import {
export const dynamic = "force-dynamic";
const SESSION_EVENTS_POLL_INTERVAL_MS = 2000;
const SESSION_EVENTS_POLL_INTERVAL_MS = 5000;
/**
* GET /api/events SSE stream for real-time lifecycle events
@ -28,6 +28,7 @@ export async function GET(request: Request): Promise<Response> {
let updates: ReturnType<typeof setInterval> | undefined;
let observerProjectId: string | undefined;
let observer: ProjectObserver | null = null;
let streamClosed = false;
const ensureObserver = (config: ServicesConfig): ProjectObserver | null => {
if (!observerProjectId) {
@ -44,8 +45,28 @@ export async function GET(request: Request): Promise<Response> {
return observer;
};
const stopStream = () => {
if (streamClosed) return;
streamClosed = true;
if (heartbeat) clearInterval(heartbeat);
if (updates) clearInterval(updates);
};
const encodeEvent = (payload: unknown) => encoder.encode(`data: ${JSON.stringify(payload)}\n\n`);
const stream = new ReadableStream({
start(controller) {
const safeEnqueue = (payload: unknown): boolean => {
if (streamClosed) return false;
try {
controller.enqueue(encodeEvent(payload));
return true;
} catch {
stopStream();
return false;
}
};
void (async () => {
try {
const { config } = await getServices();
@ -96,7 +117,7 @@ export async function GET(request: Request): Promise<Response> {
lastActivityAt: s.lastActivityAt,
})),
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`));
safeEnqueue(initialEvent);
if (projectObserver && observerProjectId) {
projectObserver.recordOperation({
metric: "sse_snapshot",
@ -108,23 +129,23 @@ export async function GET(request: Request): Promise<Response> {
level: "info",
});
}
} catch {
// If services aren't available, send empty snapshot
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: "snapshot", correlationId, emittedAt: new Date().toISOString(), sessions: [] })}\n\n`,
),
);
} catch (error) {
safeEnqueue({
type: "error",
correlationId,
emittedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : "Failed to load live dashboard data",
});
}
})();
// Send periodic heartbeat
heartbeat = setInterval(() => {
if (streamClosed) return;
try {
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
} catch {
clearInterval(heartbeat);
clearInterval(updates);
stopStream();
}
}, 15000);
@ -172,7 +193,7 @@ export async function GET(request: Request): Promise<Response> {
lastActivityAt: s.lastActivityAt,
})),
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
safeEnqueue(event);
if (projectObserver && observerProjectId) {
projectObserver.recordOperation({
metric: "sse_snapshot",
@ -184,21 +205,27 @@ export async function GET(request: Request): Promise<Response> {
level: "info",
});
}
} catch {
// enqueue failure means the stream is closed — clean up both intervals
clearInterval(updates);
clearInterval(heartbeat);
} catch (error) {
safeEnqueue({
type: "error",
correlationId,
emittedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : "Live dashboard update failed",
});
}
} catch {
// Transient service error — skip this poll, retry on next interval
return;
} catch (error) {
safeEnqueue({
type: "error",
correlationId,
emittedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : "Live dashboard update failed",
});
}
})();
}, SESSION_EVENTS_POLL_INTERVAL_MS);
},
cancel() {
clearInterval(heartbeat);
clearInterval(updates);
stopStream();
void (async () => {
try {
const { config } = await getServices();

View File

@ -7,8 +7,14 @@ import {
enrichSessionPR,
enrichSessionsMetadata,
} from "@/lib/serialize";
import { settlesWithin } from "@/lib/async-utils";
import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability";
const AGENT_REPORT_AUDIT_TIMEOUT_MS = 1000;
const METADATA_ENRICH_TIMEOUT_MS = 3000;
const PR_CACHE_ENRICH_TIMEOUT_MS = 1000;
const PR_LIVE_ENRICH_TIMEOUT_MS = 2000;
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const correlationId = getCorrelationId(_request);
const startedAt = Date.now();
@ -23,27 +29,39 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
const dashboardSession = sessionToDashboard(coreSession);
const project = resolveProject(coreSession, config.projects);
if (project) {
if (project?.storageKey) {
const sessionsDir = getSessionsDir(project.storageKey);
dashboardSession.agentReportAudit = await readAgentReportAuditTrailAsync(
sessionsDir,
coreSession.id,
);
const auditPromise = readAgentReportAuditTrailAsync(sessionsDir, coreSession.id).then((audit) => {
dashboardSession.agentReportAudit = audit;
});
await settlesWithin(auditPromise, AGENT_REPORT_AUDIT_TIMEOUT_MS);
}
// Enrich metadata (issue labels, agent summaries, issue titles)
await enrichSessionsMetadata([coreSession], [dashboardSession], config, registry);
await settlesWithin(
enrichSessionsMetadata([coreSession], [dashboardSession], config, registry),
METADATA_ENRICH_TIMEOUT_MS,
);
// Enrich PR — serve cache immediately, refresh in background if stale
if (coreSession.pr) {
const scm = getSCM(registry, project);
if (scm) {
const cached = await enrichSessionPR(dashboardSession, scm, coreSession.pr, {
cacheOnly: true,
});
let cached = false;
const cachedSettled = await settlesWithin(
enrichSessionPR(dashboardSession, scm, coreSession.pr, {
cacheOnly: true,
}).then((result) => {
cached = result;
}),
PR_CACHE_ENRICH_TIMEOUT_MS,
);
if (!cached) {
// Nothing cached yet — block once to populate, then future calls use cache
await enrichSessionPR(dashboardSession, scm, coreSession.pr);
await settlesWithin(
enrichSessionPR(dashboardSession, scm, coreSession.pr),
cachedSettled ? PR_LIVE_ENRICH_TIMEOUT_MS : PR_CACHE_ENRICH_TIMEOUT_MS + PR_LIVE_ENRICH_TIMEOUT_MS,
);
}
}
}

View File

@ -26,6 +26,10 @@
/* ── Light mode (default) ─────────────────────────────────────────── */
:root {
--font-geist-sans:
"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--font-jetbrains-mono: "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace;
/* Base surfaces — design-system light palette */
--color-bg-base: #f5f3f0;
--color-bg-surface: #ffffff;
@ -160,16 +164,16 @@
/* Alert colors */
--color-alert-ci: #dc2626;
--color-alert-ci-bg: rgba(220,38,38,0.06);
--color-alert-ci-bg: rgba(220, 38, 38, 0.06);
--color-alert-ci-unknown: #b8860b;
--color-alert-review: #2563eb;
--color-alert-review-bg: rgba(37,99,235,0.06);
--color-alert-review-bg: rgba(37, 99, 235, 0.06);
--color-alert-changes: #d97706;
--color-alert-changes-bg: rgba(217,119,6,0.06);
--color-alert-changes-bg: rgba(217, 119, 6, 0.06);
--color-alert-conflict: #ca8a04;
--color-alert-conflict-bg: rgba(202,138,4,0.06);
--color-alert-conflict-bg: rgba(202, 138, 4, 0.06);
--color-alert-comment: #7c3aed;
--color-alert-comment-bg: rgba(124,58,237,0.06);
--color-alert-comment-bg: rgba(124, 58, 237, 0.06);
}
/* ── Dark mode ────────────────────────────────────────────────────── */
@ -306,16 +310,16 @@
/* Alert colors — warm-tinted */
--color-alert-ci: #ef4444;
--color-alert-ci-bg: rgba(239,68,68,0.08);
--color-alert-ci-bg: rgba(239, 68, 68, 0.08);
--color-alert-ci-unknown: #e2a336;
--color-alert-review: #60a5fa;
--color-alert-review-bg: rgba(96,165,250,0.08);
--color-alert-review-bg: rgba(96, 165, 250, 0.08);
--color-alert-changes: #f97316;
--color-alert-changes-bg: rgba(249,115,22,0.08);
--color-alert-changes-bg: rgba(249, 115, 22, 0.08);
--color-alert-conflict: #eab308;
--color-alert-conflict-bg: rgba(234,179,8,0.08);
--color-alert-conflict-bg: rgba(234, 179, 8, 0.08);
--color-alert-comment: #a78bfa;
--color-alert-comment-bg: rgba(167,139,250,0.08);
--color-alert-comment-bg: rgba(167, 139, 250, 0.08);
}
@theme {
@ -535,6 +539,252 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
font-size: 13px;
}
.session-ended-summary {
display: flex;
min-height: 100%;
align-items: center;
justify-content: center;
padding: clamp(24px, 5vw, 56px);
background:
radial-gradient(
circle at 50% 22%,
color-mix(in srgb, var(--color-accent-amber) 10%, transparent),
transparent 30%
),
linear-gradient(
180deg,
var(--color-bg-inset),
color-mix(in srgb, var(--color-bg-base) 94%, black)
);
}
.session-ended-summary__panel {
width: min(760px, 100%);
border: 1px solid color-mix(in srgb, var(--color-border-default) 88%, transparent);
border-radius: 22px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--color-bg-elevated) 92%, transparent),
var(--color-bg-surface)
);
box-shadow: var(--detail-card-shadow);
padding: clamp(22px, 4vw, 34px);
}
.session-ended-summary__eyebrow {
width: fit-content;
margin-bottom: 18px;
border: 1px solid color-mix(in srgb, var(--color-accent-amber) 30%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--color-accent-amber) 9%, transparent);
color: var(--color-accent-amber);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.18em;
line-height: 1;
padding: 7px 10px;
text-transform: uppercase;
}
.session-ended-summary__header {
display: flex;
gap: 16px;
align-items: flex-start;
}
.session-ended-summary__icon {
display: flex;
width: 52px;
height: 52px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, var(--color-accent-amber) 24%, transparent);
border-radius: 16px;
background: color-mix(in srgb, var(--color-accent-amber) 8%, var(--color-bg-elevated));
color: var(--color-accent-amber);
}
.session-ended-summary__icon svg {
width: 26px;
height: 26px;
}
.session-ended-summary__title-group {
min-width: 0;
}
.session-ended-summary__title {
margin: 0;
color: var(--color-text-primary);
font-size: clamp(24px, 3vw, 34px);
font-weight: 650;
letter-spacing: -0.045em;
line-height: 1.05;
}
.session-ended-summary__subtitle {
max-width: 560px;
margin: 10px 0 0;
color: var(--color-text-secondary);
font-size: 14px;
line-height: 1.65;
}
.session-ended-summary__body {
display: grid;
gap: 18px;
margin-top: 28px;
}
.session-ended-summary__section {
border: 1px solid var(--color-border-subtle);
border-radius: 16px;
background: color-mix(in srgb, var(--color-bg-elevated) 72%, transparent);
padding: 16px;
}
.session-ended-summary__label {
margin-bottom: 8px;
color: var(--color-text-tertiary);
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.session-ended-summary__copy {
margin: 0;
color: var(--color-text-primary);
font-size: 14px;
line-height: 1.65;
}
.session-ended-summary__facts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.session-ended-summary__fact {
min-width: 0;
border: 1px solid var(--color-border-subtle);
border-radius: 14px;
background: color-mix(in srgb, var(--color-bg-elevated) 58%, transparent);
padding: 12px;
}
.session-ended-summary__fact span {
display: block;
margin-bottom: 6px;
color: var(--color-text-tertiary);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.session-ended-summary__fact strong {
display: block;
overflow: hidden;
color: var(--color-text-primary);
font-size: 13px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-ended-summary__links {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.session-ended-summary__primary,
.session-ended-summary__secondary {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
border-radius: 999px;
font-size: 12px;
font-weight: 650;
padding: 0 15px;
text-decoration: none;
transition:
transform 120ms ease,
border-color 120ms ease,
background 120ms ease;
}
.session-ended-summary__primary {
border: 1px solid color-mix(in srgb, var(--color-accent-amber) 60%, transparent);
background: var(--color-accent-amber);
color: var(--color-text-inverse);
}
.session-ended-summary__secondary {
border: 1px solid var(--color-border-default);
background: color-mix(in srgb, var(--color-bg-elevated) 80%, transparent);
color: var(--color-text-secondary);
}
.session-ended-summary__primary:hover,
.session-ended-summary__secondary:hover {
transform: translateY(-1px);
text-decoration: none;
}
.session-ended-summary__evidence {
display: grid;
gap: 6px;
border-top: 1px solid var(--color-border-subtle);
padding-top: 14px;
}
.session-ended-summary__evidence span {
color: var(--color-text-tertiary);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.session-ended-summary__evidence code {
color: var(--color-text-secondary);
font-family: var(--font-mono);
font-size: 12px;
white-space: pre-wrap;
}
@media (max-width: 720px) {
.session-ended-summary {
align-items: flex-start;
padding: 18px;
}
.session-ended-summary__panel {
border-radius: 18px;
padding: 20px;
}
.session-ended-summary__header {
flex-direction: column;
}
.session-ended-summary__facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 420px) {
.session-ended-summary__facts {
grid-template-columns: 1fr;
}
}
/* ── Empty State + Ghost Kanban ──────────────────────────────────────── */
.board-wrapper {
@ -660,7 +910,10 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: background 100ms ease, border-color 100ms ease, color 100ms ease,
transition:
background 100ms ease,
border-color 100ms ease,
color 100ms ease,
transform 120ms cubic-bezier(0.23, 1, 0.32, 1);
animation: empty-state-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) 135ms both;
}
@ -896,7 +1149,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: background 100ms, color 100ms;
transition:
background 100ms,
color 100ms;
}
.dashboard-app-sidebar-toggle:hover {
@ -977,6 +1232,23 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
transform: scale(0.97);
}
.topbar-action-icon {
width: 12px;
height: 12px;
flex-shrink: 0;
}
.dashboard-app-btn--restore {
border-radius: 4px;
color: var(--color-text-secondary);
}
.dashboard-app-btn--restore:hover {
background: color-mix(in srgb, var(--color-accent-amber) 4%, transparent);
border-color: var(--color-accent-amber-border);
color: var(--color-accent-amber);
}
.dashboard-app-btn--amber {
border-color: var(--color-accent-amber-border);
background: var(--color-accent-amber-dim);
@ -1020,12 +1292,22 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
color: var(--color-text-secondary);
}
/* State-specific tint backgrounds */
.topbar-status-pill--active { background: color-mix(in srgb, var(--color-status-working) 10%, transparent); }
.topbar-status-pill--ready { background: color-mix(in srgb, var(--color-status-ready) 10%, transparent); }
.topbar-status-pill--idle { background: color-mix(in srgb, var(--color-text-tertiary) 10%, transparent); }
.topbar-status-pill--waiting-for-input { background: color-mix(in srgb, var(--color-status-attention) 10%, transparent); }
.topbar-status-pill--active {
background: color-mix(in srgb, var(--color-status-working) 10%, transparent);
}
.topbar-status-pill--ready {
background: color-mix(in srgb, var(--color-status-ready) 10%, transparent);
}
.topbar-status-pill--idle {
background: color-mix(in srgb, var(--color-text-tertiary) 10%, transparent);
}
.topbar-status-pill--waiting-for-input {
background: color-mix(in srgb, var(--color-status-attention) 10%, transparent);
}
.topbar-status-pill--blocked,
.topbar-status-pill--exited { background: color-mix(in srgb, var(--color-status-error) 10%, transparent); }
.topbar-status-pill--exited {
background: color-mix(in srgb, var(--color-status-error) 10%, transparent);
}
.topbar-status-pill__dot {
width: 6px;
@ -1033,7 +1315,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
border-radius: 50%;
flex-shrink: 0;
}
.topbar-status-pill__label { color: inherit; }
.topbar-status-pill__label {
color: inherit;
}
/* Branch pill — compact topbar variant */
.topbar-branch-pill {
@ -1050,8 +1334,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
overflow: hidden;
text-overflow: ellipsis;
}
.topbar-branch-pill--link { color: var(--color-accent); }
.topbar-branch-pill--link:hover { text-decoration: underline; }
.topbar-branch-pill--link {
color: var(--color-accent);
}
.topbar-branch-pill--link:hover {
text-decoration: underline;
}
/* Danger button variant (kill) */
.dashboard-app-btn--danger {
@ -1084,9 +1372,15 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
border-radius: 50%;
flex-shrink: 0;
}
.topbar-pr-dot--green { background: var(--color-status-ready); }
.topbar-pr-dot--red { background: var(--color-status-error); }
.topbar-pr-dot--amber { background: var(--color-status-attention); }
.topbar-pr-dot--green {
background: var(--color-status-ready);
}
.topbar-pr-dot--red {
background: var(--color-status-error);
}
.topbar-pr-dot--amber {
background: var(--color-status-attention);
}
/* Popover panel */
.topbar-pr-popover {
@ -1107,7 +1401,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
/* Session title hidden on mobile (pills move to second row instead) */
@media (max-width: 640px) {
.dashboard-app-header__session-title { display: none; }
.dashboard-app-header__session-title {
display: none;
}
}
/* Mobile: popover anchors from left edge (topbar is narrow) */
@ -1211,7 +1507,10 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: background 100ms, color 100ms, border-color 100ms;
transition:
background 100ms,
color 100ms,
border-color 100ms;
}
.project-sidebar__footer-btn:hover {
@ -2032,8 +2331,16 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.session-detail-status-pill--error {
color: var(--color-status-error, var(--color-accent-red));
border-color: color-mix(in srgb, var(--color-status-error, var(--color-accent-red)) 20%, transparent);
background: color-mix(in srgb, var(--color-status-error, var(--color-accent-red)) 8%, transparent);
border-color: color-mix(
in srgb,
var(--color-status-error, var(--color-accent-red)) 20%,
transparent
);
background: color-mix(
in srgb,
var(--color-status-error, var(--color-accent-red)) 8%,
transparent
);
}
.session-detail-status-pill--neutral {
@ -2396,7 +2703,10 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
border-radius: 3px;
text-decoration: none;
white-space: nowrap;
transition: color 80ms, border-color 80ms, background 80ms;
transition:
color 80ms,
border-color 80ms,
background 80ms;
}
.card__pr:hover {
@ -2473,11 +2783,26 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
transition: background 80ms;
}
.alert-row--ci { --ar-color: var(--color-alert-ci); --ar-bg: var(--color-alert-ci-bg); }
.alert-row--changes { --ar-color: var(--color-alert-changes); --ar-bg: var(--color-alert-changes-bg); }
.alert-row--review { --ar-color: var(--color-alert-review); --ar-bg: var(--color-alert-review-bg); }
.alert-row--conflict { --ar-color: var(--color-alert-conflict); --ar-bg: var(--color-alert-conflict-bg); }
.alert-row--comment { --ar-color: var(--color-alert-comment); --ar-bg: var(--color-alert-comment-bg); }
.alert-row--ci {
--ar-color: var(--color-alert-ci);
--ar-bg: var(--color-alert-ci-bg);
}
.alert-row--changes {
--ar-color: var(--color-alert-changes);
--ar-bg: var(--color-alert-changes-bg);
}
.alert-row--review {
--ar-color: var(--color-alert-review);
--ar-bg: var(--color-alert-review-bg);
}
.alert-row--conflict {
--ar-color: var(--color-alert-conflict);
--ar-bg: var(--color-alert-conflict-bg);
}
.alert-row--comment {
--ar-color: var(--color-alert-comment);
--ar-bg: var(--color-alert-comment-bg);
}
.alert-row__icon {
flex-shrink: 0;
@ -2524,7 +2849,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
font-size: 9.5px;
font-weight: 600;
opacity: 0.85;
transition: background 100ms, color 100ms;
transition:
background 100ms,
color 100ms;
cursor: pointer;
white-space: nowrap;
text-transform: lowercase;
@ -2658,6 +2985,23 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
text-decoration: none;
}
.session-card__restore-control {
font-size: 10.5px;
font-family: var(--font-sans);
font-weight: 500;
padding: 2px 8px 2px 6px;
border-radius: 4px;
border: 1px solid var(--color-border-default);
background: transparent;
color: var(--color-text-secondary);
}
.session-card__restore-control:hover {
background: color-mix(in srgb, var(--color-accent-amber) 4%, transparent);
border-color: var(--color-accent-amber-border);
color: var(--color-accent-amber);
}
.btn--danger {
width: 26px;
height: 26px;
@ -3106,14 +3450,24 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.session-detail-blocker-chip--fail {
color: var(--color-status-error, var(--color-accent-red));
background: color-mix(in srgb, var(--color-status-error, var(--color-accent-red)) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-status-error, var(--color-accent-red)) 18%, transparent);
background: color-mix(
in srgb,
var(--color-status-error, var(--color-accent-red)) 8%,
transparent
);
border: 1px solid
color-mix(in srgb, var(--color-status-error, var(--color-accent-red)) 18%, transparent);
}
.session-detail-blocker-chip--warn {
color: var(--color-status-attention, var(--color-accent-yellow));
background: color-mix(in srgb, var(--color-status-attention, var(--color-accent-yellow)) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-status-attention, var(--color-accent-yellow)) 18%, transparent);
background: color-mix(
in srgb,
var(--color-status-attention, var(--color-accent-yellow)) 8%,
transparent
);
border: 1px solid
color-mix(in srgb, var(--color-status-attention, var(--color-accent-yellow)) 18%, transparent);
}
.session-detail-blocker-chip--muted {
@ -3146,14 +3500,24 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.session-detail-ci-chip--fail {
color: var(--color-accent-red, var(--color-status-error));
border: 1px solid color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 20%, transparent);
background: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 6%, transparent);
border: 1px solid
color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 20%, transparent);
background: color-mix(
in srgb,
var(--color-accent-red, var(--color-status-error)) 6%,
transparent
);
}
.session-detail-ci-chip--pending {
color: var(--color-accent-yellow, var(--color-status-attention));
border: 1px solid color-mix(in srgb, var(--color-accent-yellow, var(--color-status-attention)) 20%, transparent);
background: color-mix(in srgb, var(--color-accent-yellow, var(--color-status-attention)) 6%, transparent);
border: 1px solid
color-mix(in srgb, var(--color-accent-yellow, var(--color-status-attention)) 20%, transparent);
background: color-mix(
in srgb,
var(--color-accent-yellow, var(--color-status-attention)) 6%,
transparent
);
}
.session-detail-ci-chip--queued {
@ -3226,7 +3590,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
padding: 1px 6px;
border-radius: 3px;
color: var(--color-accent-red, var(--color-status-error));
background: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 12%, transparent);
background: color-mix(
in srgb,
var(--color-accent-red, var(--color-status-error)) 12%,
transparent
);
}
.session-detail-comments-strip__hint {
@ -3322,14 +3690,20 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.session-detail-comment__fix-btn {
padding: 3px 10px;
border-radius: 4px;
border: 1px solid var(--color-accent-amber-border, color-mix(in srgb, var(--color-accent) 30%, transparent));
background: var(--color-accent-amber-dim, color-mix(in srgb, var(--color-accent) 12%, transparent));
border: 1px solid
var(--color-accent-amber-border, color-mix(in srgb, var(--color-accent) 30%, transparent));
background: var(
--color-accent-amber-dim,
color-mix(in srgb, var(--color-accent) 12%, transparent)
);
color: var(--color-accent-amber, var(--color-accent));
font-family: var(--font-sans, inherit);
font-size: 10.5px;
font-weight: 600;
cursor: pointer;
transition: background 100ms, transform 100ms cubic-bezier(0.23, 1, 0.32, 1);
transition:
background 100ms,
transform 100ms cubic-bezier(0.23, 1, 0.32, 1);
}
.session-detail-comment__fix-btn:hover {
@ -3371,7 +3745,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
font-size: 11.5px;
font-weight: 500;
cursor: pointer;
transition: background 100ms, border-color 100ms, color 100ms, transform 100ms cubic-bezier(0.23, 1, 0.32, 1);
transition:
background 100ms,
border-color 100ms,
color 100ms,
transform 100ms cubic-bezier(0.23, 1, 0.32, 1);
}
.session-detail-action-btn:hover {
@ -3386,12 +3764,24 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.session-detail-action-btn--danger {
color: var(--color-accent-red, var(--color-status-error));
border-color: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 22%, var(--color-border-default));
border-color: color-mix(
in srgb,
var(--color-accent-red, var(--color-status-error)) 22%,
var(--color-border-default)
);
}
.session-detail-action-btn--danger:hover {
background: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 8%, transparent);
border-color: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 40%, var(--color-border-default));
background: color-mix(
in srgb,
var(--color-accent-red, var(--color-status-error)) 8%,
transparent
);
border-color: color-mix(
in srgb,
var(--color-accent-red, var(--color-status-error)) 40%,
var(--color-border-default)
);
color: var(--color-accent-red, var(--color-status-error));
}
@ -3470,7 +3860,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
background: transparent;
border: none;
cursor: pointer;
transition: background 100ms, color 100ms;
transition:
background 100ms,
color 100ms;
flex-shrink: 0;
}
@ -3575,7 +3967,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
flex-shrink: 0;
color: var(--color-text-muted);
border-radius: 4px;
transition: background 80ms, color 80ms;
transition:
background 80ms,
color 80ms;
text-decoration: none;
}
@ -4016,9 +4410,15 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
color: var(--color-text-primary);
}
.project-sidebar__collapsed-session-btn[data-level="respond"] { color: var(--color-status-attention); }
.project-sidebar__collapsed-session-btn[data-level="review"] { color: var(--color-status-review); }
.project-sidebar__collapsed-session-btn[data-level="working"] { color: var(--color-status-active); }
.project-sidebar__collapsed-session-btn[data-level="respond"] {
color: var(--color-status-attention);
}
.project-sidebar__collapsed-session-btn[data-level="review"] {
color: var(--color-status-review);
}
.project-sidebar__collapsed-session-btn[data-level="working"] {
color: var(--color-status-active);
}
.project-sidebar__session-abbr-first {
font-size: 10px;
@ -4620,9 +5020,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
/* ── Kanban board ────────────────────────────────────────────────────── */
.kanban-board {
--kanban-column-count: 5;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(var(--kanban-column-count), minmax(0, 1fr));
gap: 8px;
width: 100%;
min-width: 0;
height: calc(100vh - 240px);
height: calc(100dvh - 240px);
overflow-x: auto;
@ -4692,7 +5095,6 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
overflow: hidden;
}
.kanban-column__header {
flex-shrink: 0;
padding: 8px 10px 7px;
@ -4911,7 +5313,9 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.15s, color 0.15s;
transition:
border-color 0.15s,
color 0.15s;
flex-shrink: 0;
}
@ -6266,6 +6670,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.session-detail__terminal-full .direct-terminal,
.session-detail__terminal-full .direct-terminal__container,
.session-detail__terminal-full .terminal-exited-placeholder,
.session-detail__terminal-full .session-ended-summary,
.session-detail__terminal-full .session-detail-terminal-placeholder {
height: 100% !important;
min-height: 0;
@ -6936,7 +7341,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.bottom-sheet__btn--danger:hover {
background: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 92%, black 8%);
border-color: color-mix(in srgb, var(--color-accent-red, var(--color-status-error)) 92%, black 8%);
border-color: color-mix(
in srgb,
var(--color-accent-red, var(--color-status-error)) 92%,
black 8%
);
}
.bottom-sheet__btn--primary {
@ -7031,8 +7440,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
/* Mobile: hide brand, icon-only buttons */
@media (max-width: 640px) {
.dashboard-app-header__brand--hide-mobile { display: none; }
.topbar-btn-label { display: none; }
.dashboard-app-header__brand--hide-mobile {
display: none;
}
.topbar-btn-label {
display: none;
}
}
/* Mobile bottom nav is position:fixed so no padding-bottom needed on session-detail-page */
@ -7068,7 +7481,10 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
}
@media (max-width: 640px) {
.terminal-chrome-bar { padding: 4px 8px; gap: 4px; }
.terminal-chrome-bar {
padding: 4px 8px;
gap: 4px;
}
/* Compact pane label on mobile */
.terminal-chrome-pane-label {
@ -7094,8 +7510,15 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
.terminal-chrome-status-row {
gap: 4px;
}
.terminal-chrome-status-row .rounded-full { width: 5px; height: 5px; min-width: 5px; min-height: 5px; }
.terminal-chrome-status-row span { font-size: 8px !important; }
.terminal-chrome-status-row .rounded-full {
width: 5px;
height: 5px;
min-width: 5px;
min-height: 5px;
}
.terminal-chrome-status-row span {
font-size: 8px !important;
}
}
/* ── Topbar session pills ──────────────────────────────────────────── */
@ -7126,11 +7549,17 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
/* topbar-mobile-only is the inverse of topbar-desktop-only; hidden on
desktop, shown below the mobile breakpoint. */
.topbar-mobile-only { display: none; }
.topbar-mobile-only {
display: none;
}
@media (max-width: 640px) {
.topbar-desktop-only { display: none; }
.topbar-mobile-only { display: inline; }
.topbar-desktop-only {
display: none;
}
.topbar-mobile-only {
display: inline;
}
/* Stack project name above pills in the same 48px topbar */
.topbar-project-pills-group {

View File

@ -4,11 +4,6 @@ vi.mock("@/lib/project-name", () => ({
getProjectName: () => "Agent Orchestrator",
}));
vi.mock("next/font/google", () => ({
Geist: () => ({ variable: "--font-geist-sans" }),
JetBrains_Mono: () => ({ variable: "--font-jetbrains-mono" }),
}));
describe("app layout metadata", () => {
it("exports the themed mobile viewport colors", async () => {
const { viewport } = await import("./layout");

View File

@ -1,24 +1,10 @@
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { Geist, JetBrains_Mono } from "next/font/google";
import { getProjectName } from "@/lib/project-name";
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
import { Providers } from "@/app/providers";
import "./globals.css";
const geistSans = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
weight: ["400", "500"],
});
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
@ -47,11 +33,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html
lang="en"
className={`dark ${geistSans.variable} ${jetbrainsMono.variable}`}
suppressHydrationWarning
>
<html lang="en" className="dark" suppressHydrationWarning>
<body className="h-screen overflow-hidden bg-[var(--color-bg-base)] text-[var(--color-text-primary)] antialiased">
<Providers>{children}</Providers>
<ServiceWorkerRegistrar />

View File

@ -20,7 +20,7 @@ export async function generateMetadata(props: {
projectName = project.name;
}
}
return { title: { absolute: `ao | ${projectName} - Select Orchestrator` } };
return { title: { absolute: `ao | ${projectName} - Orchestrator` } };
}
export default async function OrchestratorsRoute(props: {
@ -61,7 +61,12 @@ export default async function OrchestratorsRoute(props: {
const allSessionPrefixes = Object.entries(config.projects).map(
([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""),
);
orchestrators = mapSessionsToOrchestrators(allSessions, sessionPrefix, project.name, allSessionPrefixes);
orchestrators = mapSessionsToOrchestrators(
allSessions,
sessionPrefix,
project.name,
allSessionPrefixes,
);
}
} catch (err) {
error = err instanceof Error ? err.message : "Failed to load orchestrators";

View File

@ -6,6 +6,9 @@ import { ErrorDisplay } from "@/components/ErrorDisplay";
function getSessionErrorMessage(error: Error): string {
const normalized = error.message.toLowerCase();
if (normalized.includes("timed out")) {
return "The session request did not complete in time. Check the backend process and try again once the API is responsive.";
}
if (normalized.includes("network")) {
return "The session request failed before the dashboard got a response. Check the server connection and try again.";
}
@ -18,6 +21,9 @@ function getSessionErrorMessage(error: Error): string {
if (normalized.includes("500")) {
return "The server returned an internal error while loading this session. Try re-fetching the session data.";
}
if (error.message.trim().length > 0) {
return error.message;
}
return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state.";
}

View File

@ -1,26 +1,23 @@
import React, { type ReactNode } from "react";
import { act, render, screen, cleanup } from "@testing-library/react";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import type { DashboardSession } from "@/lib/types";
import type { SessionPatch } from "@/lib/mux-protocol";
const sessionDetailSpy = vi.fn();
const notFoundError = new Error("NEXT_NOT_FOUND");
const notFoundSpy = vi.fn(() => {
throw notFoundError;
});
const replaceSpy = vi.fn();
let mockPathname = "/projects/my-app/sessions/worker-1";
let mockParams: Record<string, string> = { id: "worker-1" };
const mockMuxState: {
current?: { sessions: SessionPatch[]; status?: "connecting" | "connected" | "reconnecting" | "disconnected" };
current?: {
sessions: SessionPatch[];
status?: "connecting" | "connected" | "reconnecting" | "disconnected";
};
} = {};
vi.mock("next/navigation", () => ({
useParams: () => mockParams,
usePathname: () => mockPathname,
useRouter: () => ({ replace: replaceSpy }),
notFound: notFoundSpy,
}));
vi.mock("@/providers/MuxProvider", () => ({
@ -60,27 +57,6 @@ async function flushAsyncWork(): Promise<void> {
});
}
class TestErrorBoundary extends React.Component<
{ children: ReactNode },
{ error: Error | null }
> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
if (this.state.error) {
return <div data-testid="route-error">{this.state.error.message}</div>;
}
return this.props.children;
}
}
describe("SessionPage project polling", () => {
beforeEach(() => {
vi.useFakeTimers();
@ -108,7 +84,6 @@ describe("SessionPage project polling", () => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
notFoundSpy.mockReset();
});
it("resolves orchestrator nav once for non-orchestrator pages and skips repeated project polling", async () => {
@ -168,16 +143,30 @@ describe("SessionPage project polling", () => {
render(<SessionPage />);
await flushAsyncWork();
expect(fetch).toHaveBeenCalledWith("/api/projects");
expect(fetch).toHaveBeenCalledWith("/api/sessions/worker-1");
expect(fetch).toHaveBeenCalledWith("/api/sessions?fresh=true");
expect(fetch).toHaveBeenCalledWith(
"/api/projects",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/worker-1",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(fetch).toHaveBeenCalledWith(
"/api/sessions?fresh=true",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(fetch).toHaveBeenCalledWith("/api/sessions?project=my-app&orchestratorOnly=true&fresh=true");
expect(fetch).toHaveBeenCalledWith(
"/api/sessions?project=my-app&orchestratorOnly=true&fresh=true",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(
vi.mocked(fetch).mock.calls.filter(
([url]) => url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true",
),
vi
.mocked(fetch)
.mock.calls.filter(
([url]) => url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true",
),
).toHaveLength(1);
await act(async () => {
@ -186,9 +175,11 @@ describe("SessionPage project polling", () => {
await flushAsyncWork();
expect(
vi.mocked(fetch).mock.calls.filter(
([url]) => url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true",
),
vi
.mocked(fetch)
.mock.calls.filter(
([url]) => url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true",
),
).toHaveLength(1);
expect(
@ -261,10 +252,13 @@ describe("SessionPage project polling", () => {
});
await flushAsyncWork();
expect(fetch).toHaveBeenCalledWith("/api/sessions?project=my-app&fresh=true");
expect(fetch).toHaveBeenCalledWith(
"/api/sessions?project=my-app&fresh=true",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it("routes 404 responses through notFound()", async () => {
it("renders an inline missing-session state instead of blanking the shell", async () => {
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/projects") {
@ -291,11 +285,12 @@ describe("SessionPage project polling", () => {
render(<SessionPage />);
await flushAsyncWork();
expect(notFoundSpy).toHaveBeenCalled();
expect(screen.getByText("Session not found")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument();
expect(screen.queryByTestId("session-detail")).not.toBeInTheDocument();
});
it("throws non-404 session fetch failures to the route error boundary", async () => {
it("renders an inline error state instead of throwing the route away", async () => {
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/projects") {
@ -319,15 +314,96 @@ describe("SessionPage project polling", () => {
const { default: SessionPage } = await import("./page");
render(
<TestErrorBoundary>
<SessionPage />
</TestErrorBoundary>,
);
render(<SessionPage />);
await flushAsyncWork();
expect(screen.getByTestId("route-error")).toHaveTextContent("HTTP 500");
expect(screen.getByText("Failed to load session")).toBeInTheDocument();
expect(screen.getByText(/internal error/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument();
});
it("times out a stuck session fetch and replaces the infinite loader with an error state", async () => {
global.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/projects") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ projects: [] }),
} as Response);
}
if (url === "/api/sessions/worker-1") {
return new Promise<Response>((_, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
});
}
if (url === "/api/sessions?fresh=true") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ sessions: [] }),
} as Response);
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
const { default: SessionPage } = await import("./page");
render(<SessionPage />);
expect(screen.getByText("Loading session…")).toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(8_000);
});
await flushAsyncWork();
expect(screen.getByText("Failed to load session")).toBeInTheDocument();
expect(screen.getByText(/taking too long/i)).toBeInTheDocument();
expect(screen.queryByText("Loading session…")).not.toBeInTheDocument();
});
it("shows a recoverable unavailable state when the first session request aborts", async () => {
global.fetch = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/projects") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }],
}),
} as Response);
}
if (url === "/api/sessions/worker-1") {
return Promise.reject(new DOMException("Aborted", "AbortError"));
}
if (url === "/api/sessions?fresh=true") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ sessions: [] }),
} as Response);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
}) as typeof fetch;
const { default: SessionPage } = await import("./page");
render(<SessionPage />);
await flushAsyncWork();
expect(screen.getByText("Session unavailable")).toBeInTheDocument();
expect(screen.getByText(/backend has not returned this session yet/i)).toBeInTheDocument();
expect(screen.queryByText("Loading session…")).not.toBeInTheDocument();
});
it("marks sidebar data as loading until the sessions list resolves", async () => {
@ -453,12 +529,69 @@ describe("SessionPage project polling", () => {
render(<SessionPage />);
await flushAsyncWork();
expect(
fetchMock.mock.calls.filter(([url]) => url === "/api/projects"),
).toHaveLength(2);
expect(
fetchMock.mock.calls.filter(([url]) => url === "/api/sessions?fresh=true"),
).toHaveLength(2);
expect(fetchMock.mock.calls.filter(([url]) => url === "/api/projects")).toHaveLength(2);
expect(fetchMock.mock.calls.filter(([url]) => url === "/api/sessions?fresh=true")).toHaveLength(
2,
);
});
it("silences aborted sidebar refreshes during unmount", async () => {
const workerSession = makeWorkerSession();
const consoleErrorSpy = vi.spyOn(console, "error");
global.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/projects") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }],
}),
} as Response);
}
if (url === "/api/sessions/worker-1") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => workerSession,
} as Response);
}
if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ orchestratorId: "my-app-orchestrator" }),
} as Response);
}
if (url === "/api/sessions?fresh=true") {
return new Promise<Response>((_, reject) => {
init?.signal?.addEventListener(
"abort",
() => reject(new DOMException("The operation was aborted.", "AbortError")),
{ once: true },
);
});
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
}) as typeof fetch;
const { default: SessionPage } = await import("./page");
const rendered = render(<SessionPage />);
await flushAsyncWork();
rendered.unmount();
await flushAsyncWork();
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
"Failed to fetch sidebar sessions:",
expect.anything(),
);
});
it("surfaces sidebar fetch failures instead of leaving the loading skeleton active", async () => {

View File

@ -1,9 +1,12 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { notFound, useParams, usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useCallback, useRef, type ReactNode } from "react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { ACTIVITY_STATE, SESSION_STATUS, isOrchestratorSession } from "@aoagents/ao-core/types";
import { SessionDetail } from "@/components/SessionDetail";
import { ErrorDisplay } from "@/components/ErrorDisplay";
import { ProjectSidebar } from "@/components/ProjectSidebar";
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
import { type DashboardSession, type ActivityState, getAttentionLevel } from "@/lib/types";
import { activityIcon } from "@/lib/activity-icons";
import type { ProjectInfo } from "@/lib/project-name";
@ -12,6 +15,7 @@ import { useSSESessionActivity } from "@/hooks/useSSESessionActivity";
import { useMuxOptional } from "@/providers/MuxProvider";
import type { SessionPatch } from "@/lib/mux-protocol";
import { projectSessionPath } from "@/lib/routes";
import { fetchJsonWithTimeout } from "@/lib/client-fetch";
function truncate(s: string, max: number): string {
// Split on code points so emoji / astral characters aren't cleaved into
@ -30,7 +34,11 @@ function buildSessionTitle(
const activity = activityOverride !== undefined ? activityOverride : session.activity;
const emoji = activity ? (activityIcon[activity] ?? "") : "";
const allPrefixes = [...prefixByProject.values()];
const isOrchestrator = isOrchestratorSession(session, prefixByProject.get(session.projectId), allPrefixes);
const isOrchestrator = isOrchestratorSession(
session,
prefixByProject.get(session.projectId),
allPrefixes,
);
let detail: string;
@ -67,6 +75,9 @@ interface ProjectSessionsBody {
let cachedProjects: ProjectInfo[] | null = null;
let cachedSidebarSessions: DashboardSession[] | null = null;
const SESSION_PAGE_REFRESH_INTERVAL_MS = 2000;
const SESSION_FETCH_TIMEOUT_MS = 8000;
const PROJECT_SIDEBAR_FETCH_TIMEOUT_MS = 5000;
const PROJECTS_FETCH_TIMEOUT_MS = 5000;
const validSessionStatuses = new Set<string>(Object.values(SESSION_STATUS));
const validActivityStates = new Set<string>(Object.values(ACTIVITY_STATE));
const warnedMuxPatchValues = new Set<string>();
@ -104,7 +115,175 @@ function areSidebarSessionsEqual(
});
}
function applyMuxSessionPatches(current: DashboardSession[] | null, patches: SessionPatch[]): DashboardSession[] | null {
function isAbortLikeError(error: unknown): boolean {
if (error instanceof DOMException) {
return error.name === "AbortError";
}
if (error instanceof Error) {
const message = error.message.toLowerCase();
return message.includes("aborted") || message.includes("aborterror");
}
return false;
}
function getSessionLoadErrorMessage(error: Error): string {
const normalized = error.message.toLowerCase();
if (normalized.includes("timed out")) {
return "The session request is taking too long, so the page stopped waiting instead of spinning forever. You can retry, or return to the project and reopen a different session.";
}
if (normalized.includes("network")) {
return "The session request failed before the dashboard got a response. Check the local server connection and try again.";
}
if (normalized.includes("404")) {
return "This session is no longer available. It may have been removed while the page was open.";
}
if (normalized.includes("500")) {
return "The server returned an internal error while loading this session. Try again to re-fetch the latest state.";
}
return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state.";
}
function SidebarPlaceholder({ message }: { message: string }) {
return (
<div className="project-sidebar h-full">
<div className="space-y-3 px-3 py-4">
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-[var(--color-text-tertiary)]">
Projects
</div>
<div className="space-y-2">
{Array.from({ length: 3 }, (_, index) => (
<div
key={`sidebar-placeholder-${index}`}
className="h-10 animate-pulse border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)]"
/>
))}
</div>
<div className="pt-2 text-[12px] text-[var(--color-text-tertiary)]">{message}</div>
</div>
</div>
);
}
function SessionPageShell({
projects,
projectsLoading,
sidebarSessions,
sidebarLoading,
sidebarError,
onRetrySidebar,
activeProjectId,
activeSessionId,
children,
}: {
projects: ProjectInfo[];
projectsLoading: boolean;
sidebarSessions: DashboardSession[] | null;
sidebarLoading: boolean;
sidebarError: boolean;
onRetrySidebar: () => void;
activeProjectId?: string;
activeSessionId?: string;
children: ReactNode;
}) {
const isMobile = useMediaQuery(MOBILE_BREAKPOINT);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const handleToggleSidebar = useCallback(() => {
if (isMobile) {
setMobileSidebarOpen((current) => !current);
return;
}
setSidebarCollapsed((current) => !current);
}, [isMobile]);
return (
<div className="dashboard-app-shell">
<header className="dashboard-app-header">
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={handleToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
) : (
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
)}
</button>
<div className="dashboard-app-header__brand">
<span>Agent Orchestrator</span>
</div>
<div className="dashboard-app-header__spacer" />
</header>
<div
className={`dashboard-shell dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
>
<div
className={`sidebar-wrapper${mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""}`}
>
{projects.length > 0 ? (
<ProjectSidebar
projects={projects}
sessions={sidebarSessions}
loading={sidebarLoading}
error={sidebarError}
onRetry={onRetrySidebar}
activeProjectId={activeProjectId}
activeSessionId={activeSessionId}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onMobileClose={() => setMobileSidebarOpen(false)}
/>
) : (
<SidebarPlaceholder
message={projectsLoading ? "Loading projects..." : "Projects unavailable."}
/>
)}
</div>
{mobileSidebarOpen && (
<div className="sidebar-mobile-backdrop" onClick={() => setMobileSidebarOpen(false)} />
)}
<div className="dashboard-main dashboard-main--desktop">
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
<div className="flex-1 min-h-0">{children}</div>
</main>
</div>
</div>
</div>
);
}
function applyMuxSessionPatches(
current: DashboardSession[] | null,
patches: SessionPatch[],
): DashboardSession[] | null {
if (!current || patches.length === 0) {
return current;
}
@ -185,15 +364,22 @@ export default function SessionPage() {
sessionStorage.removeItem(`ao-session-nav:${id}`);
return JSON.parse(raw) as DashboardSession;
}
} catch { /* ignore */ }
} catch {
/* ignore */
}
return null;
})();
const [session, setSession] = useState<DashboardSession | null>(cachedSession);
const [zoneCounts, setZoneCounts] = useState<ZoneCounts | null>(null);
const [projectOrchestratorId, setProjectOrchestratorId] = useState<string | null | undefined>(undefined);
const [projectOrchestratorId, setProjectOrchestratorId] = useState<string | null | undefined>(
undefined,
);
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [sidebarSessions, setSidebarSessions] = useState<DashboardSession[] | null>(() => cachedSidebarSessions);
const [projectsLoading, setProjectsLoading] = useState(cachedProjects === null);
const [sidebarSessions, setSidebarSessions] = useState<DashboardSession[] | null>(
() => cachedSidebarSessions,
);
const [loading, setLoading] = useState(cachedSession === null);
const [routeError, setRouteError] = useState<Error | null>(null);
const [sessionMissing, setSessionMissing] = useState(false);
@ -214,6 +400,10 @@ export default function SessionPage() {
const fetchingSessionRef = useRef(false);
const fetchingProjectSessionsRef = useRef(false);
const fetchingSidebarRef = useRef(false);
const sessionFetchControllerRef = useRef<AbortController | null>(null);
const projectSessionsFetchControllerRef = useRef<AbortController | null>(null);
const sidebarFetchControllerRef = useRef<AbortController | null>(null);
const pageUnloadingRef = useRef(false);
// Keep prefixByProjectRef in sync so fetchProjectSessions (stable [] dep) reads latest map
useEffect(() => {
@ -224,33 +414,33 @@ export default function SessionPage() {
const fetchProjects = useCallback(async () => {
if (cachedProjects) {
setProjects(cachedProjects);
setPrefixByProject(
new Map(cachedProjects.map((p) => [p.id, p.sessionPrefix ?? p.id])),
);
setPrefixByProject(new Map(cachedProjects.map((p) => [p.id, p.sessionPrefix ?? p.id])));
setProjectsLoading(false);
}
try {
const res = await fetch("/api/projects");
if (!res.ok) {
console.error("Failed to fetch projects:", new Error(`HTTP ${res.status}`));
return;
}
const data = (await res.json()) as { projects?: ProjectInfo[] } | null;
const data = await fetchJsonWithTimeout<{ projects?: ProjectInfo[] } | null>(
"/api/projects",
{
timeoutMs: PROJECTS_FETCH_TIMEOUT_MS,
timeoutMessage: `Projects request timed out after ${PROJECTS_FETCH_TIMEOUT_MS}ms`,
},
);
if (!data?.projects) return;
if (!areProjectsEqual(cachedProjects, data.projects)) {
cachedProjects = data.projects;
setProjects(data.projects);
setPrefixByProject(
new Map(data.projects.map((p) => [p.id, p.sessionPrefix ?? p.id])),
);
setPrefixByProject(new Map(data.projects.map((p) => [p.id, p.sessionPrefix ?? p.id])));
}
} catch (err) {
console.error("Failed to fetch projects:", err);
} finally {
setProjectsLoading(false);
}
}, []);
// Subscribe to SSE for real-time activity updates (title emoji)
const sseActivity = useSSESessionActivity(id);
const sseActivity = useSSESessionActivity(id, sessionProjectId ?? expectedProjectId ?? undefined);
// Update document title based on session data + SSE activity override
useEffect(() => {
@ -291,22 +481,37 @@ export default function SessionPage() {
const fetchSession = useCallback(async () => {
if (fetchingSessionRef.current) return;
fetchingSessionRef.current = true;
const controller = new AbortController();
sessionFetchControllerRef.current = controller;
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`);
if (res.status === 404) {
const data = await fetchJsonWithTimeout<DashboardSession | { error: string }>(
`/api/sessions/${encodeURIComponent(id)}`,
{
signal: controller.signal,
timeoutMs: SESSION_FETCH_TIMEOUT_MS,
timeoutMessage: `Session request timed out after ${SESSION_FETCH_TIMEOUT_MS}ms`,
},
);
setSession(data as DashboardSession);
setRouteError(null);
setSessionMissing(false);
hasLoadedSessionRef.current = true;
} catch (err) {
if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) {
return;
}
const message = err instanceof Error ? err.message : "Failed to load session";
const normalizedMessage = message.toLowerCase();
if (
normalizedMessage.includes("session not found") ||
normalizedMessage.includes("http 404")
) {
if (!hasLoadedSessionRef.current) {
setSessionMissing(true);
}
setLoading(false);
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as DashboardSession;
setSession(data);
setRouteError(null);
setSessionMissing(false);
hasLoadedSessionRef.current = true;
} catch (err) {
console.error("Failed to fetch session:", err);
if (!hasLoadedSessionRef.current) {
setRouteError(err instanceof Error ? err : new Error("Failed to load session"));
@ -314,6 +519,9 @@ export default function SessionPage() {
} finally {
setLoading(false);
fetchingSessionRef.current = false;
if (sessionFetchControllerRef.current === controller) {
sessionFetchControllerRef.current = null;
}
}
}, [id]);
@ -325,22 +533,25 @@ export default function SessionPage() {
const projectSessionsKey = `${projectId}:${isOrchestrator ? "orchestrator" : "worker"}`;
if (!isOrchestrator && resolvedProjectSessionsKeyRef.current === projectSessionsKey) return;
fetchingProjectSessionsRef.current = true;
const controller = new AbortController();
projectSessionsFetchControllerRef.current = controller;
try {
const query = isOrchestrator
? `/api/sessions?project=${encodeURIComponent(projectId)}&fresh=true`
: `/api/sessions?project=${encodeURIComponent(projectId)}&orchestratorOnly=true&fresh=true`;
const res = await fetch(query);
if (!res.ok) {
console.error("Failed to fetch project sessions for", projectId, new Error(`HTTP ${res.status}`));
return;
}
const body = (await res.json()) as ProjectSessionsBody;
const body = await fetchJsonWithTimeout<ProjectSessionsBody>(query, {
signal: controller.signal,
timeoutMs: PROJECT_SIDEBAR_FETCH_TIMEOUT_MS,
timeoutMessage: `Project sessions request timed out after ${PROJECT_SIDEBAR_FETCH_TIMEOUT_MS}ms`,
});
const sessions = body.sessions ?? [];
const orchestratorId =
body.orchestratorId ??
body.orchestrators?.find((orchestrator) => orchestrator.projectId === projectId)?.id ??
null;
setProjectOrchestratorId((current) => (current === orchestratorId ? current : orchestratorId));
setProjectOrchestratorId((current) =>
current === orchestratorId ? current : orchestratorId,
);
if (!isOrchestrator) {
resolvedProjectSessionsKeyRef.current = projectSessionsKey;
@ -366,36 +577,53 @@ export default function SessionPage() {
}
}
setZoneCounts(counts);
} catch {
// non-critical - status strip just won't show
} catch (err) {
if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) {
return;
}
console.error("Failed to fetch project sessions:", err);
} finally {
fetchingProjectSessionsRef.current = false;
if (projectSessionsFetchControllerRef.current === controller) {
projectSessionsFetchControllerRef.current = null;
}
}
}, []);
const fetchSidebarSessions = useCallback(async () => {
if (fetchingSidebarRef.current) return;
fetchingSidebarRef.current = true;
const controller = new AbortController();
sidebarFetchControllerRef.current = controller;
try {
const res = await fetch("/api/sessions?fresh=true");
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const body = (await res.json()) as { sessions?: DashboardSession[] } | null;
const body = await fetchJsonWithTimeout<{ sessions?: DashboardSession[] } | null>(
"/api/sessions?fresh=true",
{
signal: controller.signal,
timeoutMs: PROJECT_SIDEBAR_FETCH_TIMEOUT_MS,
timeoutMessage: `Sidebar sessions request timed out after ${PROJECT_SIDEBAR_FETCH_TIMEOUT_MS}ms`,
},
);
const restSessions = body?.sessions ?? [];
const nextSessions =
applyMuxSessionPatches(restSessions, pendingMuxSessionsRef.current ?? []) ?? restSessions;
cachedSidebarSessions = nextSessions;
setSidebarError(false);
setSidebarSessions((current) => (
areSidebarSessionsEqual(current, nextSessions) ? current : nextSessions
));
setSidebarSessions((current) =>
areSidebarSessionsEqual(current, nextSessions) ? current : nextSessions,
);
} catch (err) {
if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) {
return;
}
console.error("Failed to fetch sidebar sessions:", err);
setSidebarError(true);
setSidebarSessions((current) => (current === null ? [] : current));
} finally {
fetchingSidebarRef.current = false;
if (sidebarFetchControllerRef.current === controller) {
sidebarFetchControllerRef.current = null;
}
}
}, []);
@ -448,11 +676,7 @@ export default function SessionPage() {
// Initial fetch — load independent sidebar/session data in parallel.
useEffect(() => {
void Promise.all([
fetchProjects(),
fetchSession(),
fetchSidebarSessions(),
]);
void Promise.all([fetchProjects(), fetchSession(), fetchSidebarSessions()]);
}, [fetchProjects, fetchSession, fetchSidebarSessions]);
useEffect(() => {
@ -471,25 +695,150 @@ export default function SessionPage() {
return () => clearInterval(interval);
}, [fetchSession, fetchProjectSessions, fetchSidebarSessions]);
useEffect(() => {
pageUnloadingRef.current = false;
const markPageUnloading = () => {
pageUnloadingRef.current = true;
};
window.addEventListener("pagehide", markPageUnloading);
window.addEventListener("beforeunload", markPageUnloading);
return () => {
window.removeEventListener("pagehide", markPageUnloading);
window.removeEventListener("beforeunload", markPageUnloading);
};
}, []);
useEffect(() => {
return () => {
sessionFetchControllerRef.current?.abort();
projectSessionsFetchControllerRef.current?.abort();
sidebarFetchControllerRef.current?.abort();
};
}, []);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--color-bg-base)]">
<div className="text-[13px] text-[var(--color-text-tertiary)]">Loading session</div>
</div>
<SessionPageShell
projects={projects}
projectsLoading={projectsLoading}
sidebarSessions={sidebarSessions}
sidebarLoading={sidebarSessions === null}
sidebarError={sidebarError}
onRetrySidebar={fetchSidebarSessions}
activeProjectId={expectedProjectId ?? undefined}
activeSessionId={id}
>
<div className="flex h-full min-h-screen items-center justify-center bg-[var(--color-bg-base)]">
<div className="flex flex-col items-center gap-3">
<svg
className="h-5 w-5 animate-spin text-[var(--color-text-tertiary)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
<div className="text-[13px] text-[var(--color-text-tertiary)]">Loading session</div>
</div>
</div>
</SessionPageShell>
);
}
if (sessionMissing) {
notFound();
return null;
return (
<SessionPageShell
projects={projects}
projectsLoading={projectsLoading}
sidebarSessions={sidebarSessions}
sidebarLoading={sidebarSessions === null}
sidebarError={sidebarError}
onRetrySidebar={fetchSidebarSessions}
activeProjectId={expectedProjectId ?? undefined}
activeSessionId={id}
>
<ErrorDisplay
title="Session not found"
message="This session is no longer available. It may have been removed, renamed, or already cleaned up."
tone="not-found"
primaryAction={{
label: "Back to dashboard",
href: expectedProjectId ? `/projects/${expectedProjectId}` : "/",
}}
secondaryAction={{ label: "Retry", onClick: () => void fetchSession() }}
compact
chrome="card"
/>
</SessionPageShell>
);
}
if (routeError) {
throw routeError;
return (
<SessionPageShell
projects={projects}
projectsLoading={projectsLoading}
sidebarSessions={sidebarSessions}
sidebarLoading={sidebarSessions === null}
sidebarError={sidebarError}
onRetrySidebar={fetchSidebarSessions}
activeProjectId={session?.projectId ?? expectedProjectId ?? undefined}
activeSessionId={id}
>
<ErrorDisplay
title="Failed to load session"
message={getSessionLoadErrorMessage(routeError)}
tone="error"
primaryAction={{
label: "Try again",
onClick: () => {
setRouteError(null);
setSessionMissing(false);
setLoading(true);
void Promise.all([fetchProjects(), fetchSession(), fetchSidebarSessions()]);
},
}}
secondaryAction={{
label: "Back to dashboard",
href: session?.projectId ? `/projects/${session.projectId}` : "/",
}}
error={routeError}
compact
chrome="card"
/>
</SessionPageShell>
);
}
if (!session) {
throw new Error("Session data was unavailable after loading completed");
return (
<SessionPageShell
projects={projects}
projectsLoading={projectsLoading}
sidebarSessions={sidebarSessions}
sidebarLoading={sidebarSessions === null}
sidebarError={sidebarError}
onRetrySidebar={fetchSidebarSessions}
activeProjectId={expectedProjectId ?? undefined}
activeSessionId={id}
>
<ErrorDisplay
title="Session unavailable"
message="The backend has not returned this session yet. This can happen right after spawning an orchestrator; retry once the terminal registers the session."
tone="error"
primaryAction={{ label: "Retry", onClick: () => void fetchSession() }}
secondaryAction={{
label: "Back to dashboard",
href: expectedProjectId ? `/projects/${expectedProjectId}` : "/",
}}
compact
chrome="card"
/>
</SessionPageShell>
);
}
return (

View File

@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery";
import {
NON_RESTORABLE_STATUSES,
@ -105,7 +105,14 @@ function DoneCard({
className="done-card__pr"
onClick={(e) => e.stopPropagation()}
>
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="1.8" viewBox="0 0 24 24">
<svg
width="9"
height="9"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
viewBox="0 0 24 24"
>
<circle cx="18" cy="18" r="3" />
<circle cx="6" cy="6" r="3" />
<path d="M6 9v3a6 6 0 0 0 6 6h3" />
@ -142,7 +149,8 @@ function DashboardInner({
}: DashboardProps) {
const orchestratorLinks = orchestrators ?? EMPTY_ORCHESTRATORS;
const mux = useMuxOptional();
const kanbanLevels = attentionZones === "detailed" ? DETAILED_KANBAN_LEVELS : SIMPLE_KANBAN_LEVELS;
const kanbanLevels =
attentionZones === "detailed" ? DETAILED_KANBAN_LEVELS : SIMPLE_KANBAN_LEVELS;
const initialAttentionLevels = useMemo(() => {
const levels: Record<string, AttentionLevel> = {};
for (const s of initialSessions) {
@ -150,16 +158,21 @@ function DashboardInner({
}
return levels;
}, [initialSessions, attentionZones]);
const { sessions, connectionStatus, sseAttentionLevels, liveSessionsResolved } = useSessionEvents({
initialSessions,
project: projectId,
muxSessions: mux?.status === "connected" ? mux.sessions : undefined,
initialAttentionLevels,
attentionZones,
});
const { sessions, connectionStatus, sseAttentionLevels, liveSessionsResolved, loadError } =
useSessionEvents({
initialSessions,
project: projectId,
muxSessions: mux?.status === "connected" ? mux.sessions : undefined,
initialAttentionLevels,
attentionZones,
});
const recoveredFromLoadError = Boolean(dashboardLoadError) && liveSessionsResolved;
const visibleDashboardLoadError = recoveredFromLoadError ? undefined : dashboardLoadError;
const visibleDashboardLoadError =
loadError ?? (recoveredFromLoadError ? undefined : dashboardLoadError);
const searchParams = useSearchParams();
const router = useRouter();
const routerRef = useRef(router);
routerRef.current = router;
const activeSessionId = searchParams.get("session") ?? undefined;
const [rateLimitDismissed, setRateLimitDismissed] = useState(false);
const [activeOrchestrators, setActiveOrchestrators] =
@ -195,9 +208,11 @@ function DashboardInner({
Boolean(projectId) &&
projects.some((project) => project.id === projectId && !project.resolveError) &&
!orchestratorHref;
const activeProject = projectId ? projects.find((project) => project.id === projectId) ?? null : null;
const activeProject = projectId
? (projects.find((project) => project.id === projectId) ?? null)
: null;
const isSpawningCurrentProject = projectId ? spawningProjectIds.includes(projectId) : false;
const currentProjectSpawnError = projectId ? spawnErrors[projectId] ?? null : null;
const currentProjectSpawnError = projectId ? (spawnErrors[projectId] ?? null) : null;
const displaySessions = useMemo(() => {
if (allProjectsView || !activeSessionId) return sessions;
@ -219,7 +234,6 @@ function DashboardInner({
setMobileMenuOpen(false);
}, [searchParams]);
const grouped = useMemo(() => {
const zones: Record<AttentionLevel, DashboardSession[]> = {
merge: [],
@ -279,7 +293,6 @@ function DashboardInner({
});
}, [activeOrchestrators, allProjectsView, attentionZones, projects, sessionsByProject]);
const handleSend = useCallback(
async (sessionId: string, message: string) => {
try {
@ -342,7 +355,6 @@ function DashboardInner({
[killSession],
);
const handleMerge = useCallback(
async (prNumber: number) => {
try {
@ -375,6 +387,7 @@ function DashboardInner({
showToast(`Restore failed: ${text}`, "error");
} else {
showToast("Session restored", "success");
routerRef.current.refresh();
}
} catch (error) {
console.error(`Network error restoring ${sessionId}:`, error);
@ -431,11 +444,15 @@ function DashboardInner({
role="alert"
aria-live="assertive"
>
<span className="font-semibold text-[var(--color-status-error)]">Orchestrator failed to load</span>
<span className="break-words text-[var(--color-text-secondary)]">{visibleDashboardLoadError}</span>
<span className="font-semibold text-[var(--color-status-error)]">
Orchestrator failed to load
</span>
<span className="break-words text-[var(--color-text-secondary)]">
{visibleDashboardLoadError}
</span>
<span className="text-[var(--color-text-secondary)]">
Confirm <span className="font-mono text-[10px]">agent-orchestrator.yaml</span> exists and is valid, then run{" "}
<span className="font-mono text-[10px]">ao doctor</span> for diagnostics.
Confirm <span className="font-mono text-[10px]">agent-orchestrator.yaml</span> exists and is
valid, then run <span className="font-mono text-[10px]">ao doctor</span> for diagnostics.
</span>
</div>
) : null;
@ -460,7 +477,9 @@ function DashboardInner({
};
return (
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen: mobileMenuOpen }}>
<SidebarContext.Provider
value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen: mobileMenuOpen }}
>
<>
<ConnectionBar status={connectionStatus} />
<div className="dashboard-app-shell">
@ -473,7 +492,15 @@ function DashboardInner({
aria-label="Toggle sidebar"
>
{isMobile ? (
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
) : (
@ -561,7 +588,9 @@ function DashboardInner({
className={`dashboard-shell dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
>
{showSidebar && (
<div className={`sidebar-wrapper${mobileMenuOpen ? " sidebar-wrapper--mobile-open" : ""}`}>
<div
className={`sidebar-wrapper${mobileMenuOpen ? " sidebar-wrapper--mobile-open" : ""}`}
>
<ProjectSidebar
projects={projects}
sessions={sessions}
@ -634,7 +663,15 @@ function DashboardInner({
{!allProjectsView && hasAnySessions && (
<div className="kanban-board-wrap">
<div className="kanban-board">
<div
className="kanban-board"
data-columns={kanbanLevels.length}
style={
{
"--kanban-column-count": kanbanLevels.length,
} as React.CSSProperties
}
>
{kanbanLevels.map((level) => (
<AttentionZone
key={level}
@ -739,7 +776,7 @@ function ProjectOverviewGrid({
}) {
return (
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{overviews.map(({ project, orchestrator, sessionCount, openPRCount, counts }) => (
{overviews.map(({ project, orchestrator, sessionCount, openPRCount, counts }) =>
(() => {
const isDegraded = Boolean(project.resolveError);
const projectHref = projectDashboardPath(project.id);
@ -760,7 +797,9 @@ function ProjectOverviewGrid({
) : (
<>
{sessionCount} active session{sessionCount !== 1 ? "s" : ""}
{openPRCount > 0 ? ` · ${openPRCount} open PR${openPRCount !== 1 ? "s" : ""}` : ""}
{openPRCount > 0
? ` · ${openPRCount} open PR${openPRCount !== 1 ? "s" : ""}`
: ""}
</>
)}
</div>
@ -793,8 +832,8 @@ function ProjectOverviewGrid({
{isDegraded
? "Project config could not be resolved"
: orchestrator
? "Per-project orchestrator available"
: "No running orchestrator"}
? "Per-project orchestrator available"
: "No running orchestrator"}
</div>
{isDegraded ? (
<Link
@ -818,7 +857,9 @@ function ProjectOverviewGrid({
disabled={spawningProjectIds.includes(project.id)}
className="orchestrator-btn px-3 py-1.5 text-[11px] font-semibold disabled:cursor-wait disabled:opacity-70"
>
{spawningProjectIds.includes(project.id) ? "Spawning..." : "Spawn Orchestrator"}
{spawningProjectIds.includes(project.id)
? "Spawning..."
: "Spawn Orchestrator"}
</button>
)}
</div>
@ -830,8 +871,8 @@ function ProjectOverviewGrid({
</div>
</section>
);
})()
))}
})(),
)}
</div>
);
}

View File

@ -154,7 +154,7 @@ export function OrchestratorSelector({
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
{projectName}
</h1>
<p className="text-sm text-[var(--color-text-secondary)]">Select an orchestrator</p>
<p className="text-sm text-[var(--color-text-secondary)]">Project orchestrator</p>
</div>
<Link
href={projectDashboardPath(projectId)}
@ -171,19 +171,15 @@ export function OrchestratorSelector({
{/* Info banner */}
<div className="mb-6 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] p-4">
<p className="text-sm text-[var(--color-text-secondary)]">
Found{" "}
<span className="font-medium text-[var(--color-text-primary)]">
{orchestrators.length}
</span>{" "}
existing orchestrator session{orchestrators.length !== 1 ? "s" : ""}. You can resume
an existing session or start a new one.
AO keeps one main orchestrator per project. Opening or spawning here reuses that
canonical session instead of creating another duplicate.
</p>
</div>
{/* Existing orchestrators */}
<div className="mb-6">
<h2 className="mb-3 text-sm font-medium text-[var(--color-text-secondary)]">
Existing Sessions
Main Session
</h2>
<div className="space-y-2">
{orchestrators.map((orch) => (
@ -227,7 +223,7 @@ export function OrchestratorSelector({
{/* Start new section */}
<div className="border-t border-[var(--color-border-subtle)] pt-6">
<h2 className="mb-3 text-sm font-medium text-[var(--color-text-secondary)]">
Or Start Fresh
Open Orchestrator
</h2>
<button
type="button"
@ -260,10 +256,10 @@ export function OrchestratorSelector({
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Creating new orchestrator...
Opening orchestrator...
</span>
) : (
"Start New Orchestrator"
"Open Orchestrator"
)}
</button>
{spawnError && (

View File

@ -27,14 +27,7 @@ interface ProjectSidebarProps {
onMobileClose?: () => void;
}
type SessionDotLevel =
| "respond"
| "review"
| "action"
| "pending"
| "working"
| "merge"
| "done";
type SessionDotLevel = "respond" | "review" | "action" | "pending" | "working" | "merge" | "done";
function SessionDot({ level }: { level: SessionDotLevel }) {
return (
@ -201,11 +194,13 @@ function ProjectSidebarInner({
// Only include sessions whose projectId matches a configured project
if (!validProjectIds.has(s.projectId)) continue;
if (isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes)) continue;
// Filter by status visibility — use getAttentionLevel so the collected
// set matches what the expanded/collapsed rendering below actually shows.
// Otherwise the project badge count can disagree with the visible rows.
if (s.status === "killed" && !showKilled) continue;
if (getAttentionLevel(s) === "done" && !showDone) continue;
// Keep terminal sessions visible when they still need human attention.
// Otherwise ACTION-column cards disappear from the sidebar just because
// their runtime has ended.
const level = getAttentionLevel(s);
if (level === "done") {
if (s.status === "killed" ? !showKilled && !showDone : !showDone) continue;
}
const list = map.get(s.projectId) ?? [];
list.push(s);
map.set(s.projectId, list);
@ -279,9 +274,11 @@ function ProjectSidebarInner({
if (collapsed) {
return (
<aside className={cn(
"project-sidebar project-sidebar--collapsed flex flex-col h-full items-center py-2 gap-1 overflow-y-auto",
)}>
<aside
className={cn(
"project-sidebar project-sidebar--collapsed flex flex-col h-full items-center py-2 gap-1 overflow-y-auto",
)}
>
{visibleProjects.map((project, idx) => {
const workerSessions = sessionsByProject.get(project.id) ?? [];
// sessionsByProject already applies the showDone filter consistently.
@ -304,7 +301,9 @@ function ProjectSidebarInner({
{visibleSessions.slice(0, 5).map((session) => {
const level = getAttentionLevel(session);
const rawTitle = session.branch ?? getSessionTitle(session);
const displayTitle = session.branch ? humanizeBranch(session.branch) || rawTitle : rawTitle;
const displayTitle = session.branch
? humanizeBranch(session.branch) || rawTitle
: rawTitle;
const abbr = displayTitle.replace(/\s+/g, "").slice(0, 3).toUpperCase();
const isActive = activeSessionId === session.id;
const sessionHref = projectSessionPath(project.id, session.id);
@ -331,7 +330,9 @@ function ProjectSidebarInner({
);
})}
{visibleSessions.length > 5 && (
<span className="project-sidebar__collapsed-overflow">+{visibleSessions.length - 5}</span>
<span className="project-sidebar__collapsed-overflow">
+{visibleSessions.length - 5}
</span>
)}
</div>
);
@ -341,393 +342,447 @@ function ProjectSidebarInner({
}
return (
<aside
className="project-sidebar flex h-full flex-col"
>
<div className="project-sidebar__compact-hdr">
<span className="project-sidebar__sect-label">Projects</span>
<button
type="button"
className="project-sidebar__add-btn"
aria-label="New project"
onClick={() => setAddProjectOpen(true)}
>
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
<aside className="project-sidebar flex h-full flex-col">
<div className="project-sidebar__compact-hdr">
<span className="project-sidebar__sect-label">Projects</span>
<button
type="button"
className="project-sidebar__add-btn"
aria-label="New project"
onClick={() => setAddProjectOpen(true)}
>
<svg fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
{/* Stale-data banner: keep cached sessions visible on fetch failure but
{/* Stale-data banner: keep cached sessions visible on fetch failure but
surface the error so users know the list may be out of date. */}
{error && sessions && sessions.length > 0 ? (
<div
role="status"
className="mx-3 mb-2 flex items-center justify-between gap-2 rounded-md border border-[var(--color-border-strong)] bg-[var(--color-bg-primary)] px-2 py-1.5 text-[11px] text-[var(--color-text-tertiary)]"
>
<span>Failed to refresh · showing cached sessions</span>
{onRetry ? (
<button
type="button"
onClick={onRetry}
className="font-medium text-[var(--color-link)] hover:underline"
>
Retry
</button>
) : null}
</div>
) : null}
{error && sessions && sessions.length > 0 ? (
<div
role="status"
className="mx-3 mb-2 flex items-center justify-between gap-2 rounded-md border border-[var(--color-border-strong)] bg-[var(--color-bg-primary)] px-2 py-1.5 text-[11px] text-[var(--color-text-tertiary)]"
>
<span>Failed to refresh · showing cached sessions</span>
{onRetry ? (
<button
type="button"
onClick={onRetry}
className="font-medium text-[var(--color-link)] hover:underline"
>
Retry
</button>
) : null}
</div>
) : null}
{/* Project tree */}
<div className="project-sidebar__tree flex-1 overflow-y-auto overflow-x-hidden">
{visibleProjects.map((project) => {
const workerSessions = sessionsByProject.get(project.id) ?? [];
const isExpanded = expandedProjects.has(project.id);
const isActive = activeProjectId === project.id;
const isDegraded = Boolean(project.resolveError);
const projectHref = projectDashboardPath(project.id);
// sessionsByProject already applies the showDone filter consistently.
const visibleSessions = workerSessions;
const hasActiveSessions = visibleSessions.length > 0;
{/* Project tree */}
<div className="project-sidebar__tree flex-1 overflow-y-auto overflow-x-hidden">
{visibleProjects.map((project) => {
const workerSessions = sessionsByProject.get(project.id) ?? [];
const isExpanded = expandedProjects.has(project.id);
const isActive = activeProjectId === project.id;
const isDegraded = Boolean(project.resolveError);
const projectHref = projectDashboardPath(project.id);
// sessionsByProject already applies the showDone filter consistently.
const visibleSessions = workerSessions;
const hasActiveSessions = visibleSessions.length > 0;
const orchestratorSession = sessions?.find(
(s) => isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes) && s.projectId === project.id
);
return (
<div key={project.id} className="project-sidebar__project">
{/* Project row: toggle + action buttons */}
<div className="project-sidebar__proj-row flex items-center">
{isDegraded ? (
<a
href={projectHref}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
navigate(projectHref);
}}
className={cn(
"project-sidebar__proj-toggle project-sidebar__proj-toggle--link project-sidebar__proj-toggle--degraded",
isActive && "project-sidebar__proj-toggle--active",
)}
aria-current={isActive ? "page" : undefined}
>
<svg
className="project-sidebar__proj-chevron project-sidebar__proj-chevron--degraded"
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M12 9v4" />
<path d="M12 17h.01" />
<path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.72 3h16.92a2 2 0 0 0 1.72-3L13.7 3.86a2 2 0 0 0-3.4 0Z" />
</svg>
<span className="project-sidebar__proj-name">{project.name}</span>
<span className="project-sidebar__proj-badge project-sidebar__proj-badge--degraded">
degraded
</span>
</a>
) : (
<button
type="button"
onClick={() => toggleExpand(project.id)}
className={cn(
"project-sidebar__proj-toggle",
isActive && "project-sidebar__proj-toggle--active",
)}
aria-expanded={isExpanded}
aria-current={isActive ? "page" : undefined}
>
<svg
className={cn(
"project-sidebar__proj-chevron",
isExpanded && "project-sidebar__proj-chevron--open",
)}
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
>
<path d="m9 18 6-6-6-6" />
</svg>
<span className="project-sidebar__proj-name">{project.name}</span>
<span
className={cn(
"project-sidebar__proj-badge",
hasActiveSessions && "project-sidebar__proj-badge--active",
)}
>
{workerSessions.length}
</span>
</button>
)}
{/* Dashboard button */}
{!isDegraded ? (
<Link
href={projectHref}
onClick={(e) => { e.stopPropagation(); onMobileClose?.(); }}
className="project-sidebar__proj-action"
aria-label={`Open ${project.name} dashboard`}
title="Dashboard"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M3 13h8V3H3zm10 8h8V11h-8zM3 21h8v-6H3zm10-10h8V3h-8z" />
</svg>
</Link>
) : null}
{/* Orchestrator button */}
{!isDegraded && orchestratorSession && (
<Link
href={projectSessionPath(project.id, orchestratorSession.id)}
onClick={(e) => { e.stopPropagation(); onMobileClose?.(); }}
className="project-sidebar__proj-action"
aria-label={`Open ${project.name} orchestrator`}
title="Orchestrator"
>
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" /><circle cx="12" cy="17" r="2" /><circle cx="18" cy="17" r="2" />
</svg>
</Link>
)}
<div
className="project-sidebar__proj-menu"
ref={projectMenuOpenId === project.id ? projectMenuRef : undefined}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setProjectMenuOpenId((current) => (current === project.id ? null : project.id));
}}
className="project-sidebar__proj-action project-sidebar__proj-action--menu"
aria-label={`Project actions for ${project.name}`}
aria-expanded={projectMenuOpenId === project.id}
aria-haspopup="menu"
title="Project actions"
>
<svg width="12" height="12" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="5" r="1.75" />
<circle cx="12" cy="12" r="1.75" />
<circle cx="12" cy="19" r="1.75" />
</svg>
</button>
{projectMenuOpenId === project.id ? (
<div
ref={projectMenuPopoverRef}
className="project-sidebar__proj-menu-popover"
role="menu"
aria-label={`${project.name} actions`}
>
<button
type="button"
className="project-sidebar__proj-menu-item"
role="menuitem"
onClick={() => {
setProjectMenuOpenId(null);
setProjectSettingsProjectId(project.id);
}}
>
Project settings
</button>
<button
type="button"
className="project-sidebar__proj-menu-item project-sidebar__proj-menu-item--danger"
role="menuitem"
onClick={() => void handleRemoveProject(project)}
disabled={deletingProjectId === project.id}
>
{deletingProjectId === project.id ? "Removing..." : "Remove project"}
</button>
</div>
) : null}
</div>
</div>
const orchestratorSession = sessions?.find(
(s) =>
isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes) &&
s.projectId === project.id,
);
return (
<div key={project.id} className="project-sidebar__project">
{/* Project row: toggle + action buttons */}
<div className="project-sidebar__proj-row flex items-center">
{isDegraded ? (
<div className="project-sidebar__degraded-note">Config needs repair</div>
<a
href={projectHref}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
navigate(projectHref);
}}
className={cn(
"project-sidebar__proj-toggle project-sidebar__proj-toggle--link project-sidebar__proj-toggle--degraded",
isActive && "project-sidebar__proj-toggle--active",
)}
aria-current={isActive ? "page" : undefined}
>
<svg
className="project-sidebar__proj-chevron project-sidebar__proj-chevron--degraded"
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M12 9v4" />
<path d="M12 17h.01" />
<path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.72 3h16.92a2 2 0 0 0 1.72-3L13.7 3.86a2 2 0 0 0-3.4 0Z" />
</svg>
<span className="project-sidebar__proj-name">{project.name}</span>
<span className="project-sidebar__proj-badge project-sidebar__proj-badge--degraded">
degraded
</span>
</a>
) : (
<button
type="button"
onClick={() => toggleExpand(project.id)}
className={cn(
"project-sidebar__proj-toggle",
isActive && "project-sidebar__proj-toggle--active",
)}
aria-expanded={isExpanded}
aria-current={isActive ? "page" : undefined}
>
<svg
className={cn(
"project-sidebar__proj-chevron",
isExpanded && "project-sidebar__proj-chevron--open",
)}
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
>
<path d="m9 18 6-6-6-6" />
</svg>
<span className="project-sidebar__proj-name">{project.name}</span>
<span
className={cn(
"project-sidebar__proj-badge",
hasActiveSessions && "project-sidebar__proj-badge--active",
)}
>
{workerSessions.length}
</span>
</button>
)}
{/* Dashboard button */}
{!isDegraded ? (
<Link
href={projectHref}
onClick={(e) => {
e.stopPropagation();
onMobileClose?.();
}}
className="project-sidebar__proj-action"
aria-label={`Open ${project.name} dashboard`}
title="Dashboard"
>
<svg
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M3 13h8V3H3zm10 8h8V11h-8zM3 21h8v-6H3zm10-10h8V3h-8z" />
</svg>
</Link>
) : null}
{/* Sessions */}
{!isDegraded && isExpanded && (
<div className="project-sidebar__sessions">
{isLoading ? (
<div className="space-y-2 px-3 py-2" aria-label="Loading sessions">
{Array.from({ length: 3 }, (_, index) => (
<div
key={`${project.id}-loading-${index}`}
className="flex items-center gap-3 border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] px-2 py-2"
>
<div className="h-2 w-2 shrink-0 animate-pulse bg-[var(--color-border-strong)]" />
<div className="h-3 flex-1 animate-pulse bg-[var(--color-bg-primary)]" />
<div className="h-3 w-12 animate-pulse bg-[var(--color-bg-primary)]" />
</div>
))}
</div>
) : visibleSessions.length > 0 ? (
visibleSessions.map((session) => {
const level = getAttentionLevel(session);
const isSessionActive = activeSessionId === session.id;
const title = session.branch ?? getSessionTitle(session);
const sessionHref = projectSessionPath(project.id, session.id);
return (
<a
key={session.id}
href={sessionHref}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
navigate(sessionHref, session);
}}
className={cn(
"project-sidebar__sess-row",
isSessionActive && "project-sidebar__sess-row--active",
)}
aria-current={isSessionActive ? "page" : undefined}
aria-label={`Open ${title}`}
>
<SessionDot level={level} />
<div className="flex-1 min-w-0">
<span
className={cn(
"project-sidebar__sess-label",
isSessionActive && "project-sidebar__sess-label--active",
)}
>
{title}
</span>
{showSessionId ? (
<div className="project-sidebar__sess-meta">
<span className="project-sidebar__sess-id">{session.id}</span>
<span className="project-sidebar__sess-status">
{LEVEL_LABELS[level]}
</span>
</div>
) : null}
</div>
{!showSessionId ? (
<span className="project-sidebar__sess-status project-sidebar__sess-status--inline">
{LEVEL_LABELS[level]}
</span>
) : null}
</a>
);
})
) : error ? (
<div className="px-3 py-2">
<div className="project-sidebar__empty">Failed to load sessions</div>
<button
type="button"
className="mt-2 text-xs font-medium text-[var(--color-link)] hover:underline"
onClick={onRetry}
>
Retry
</button>
</div>
) : (
<div className="project-sidebar__empty">No active sessions</div>
)}
</div>
{/* Orchestrator button */}
{!isDegraded && orchestratorSession && (
<Link
href={projectSessionPath(project.id, orchestratorSession.id)}
onClick={(e) => {
e.stopPropagation();
onMobileClose?.();
}}
className="project-sidebar__proj-action"
aria-label={`Open ${project.name} orchestrator`}
title="Orchestrator"
>
<svg
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" />
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
</svg>
</Link>
)}
<div
className="project-sidebar__proj-menu"
ref={projectMenuOpenId === project.id ? projectMenuRef : undefined}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setProjectMenuOpenId((current) =>
current === project.id ? null : project.id,
);
}}
className="project-sidebar__proj-action project-sidebar__proj-action--menu"
aria-label={`Project actions for ${project.name}`}
aria-expanded={projectMenuOpenId === project.id}
aria-haspopup="menu"
title="Project actions"
>
<svg
width="12"
height="12"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="5" r="1.75" />
<circle cx="12" cy="12" r="1.75" />
<circle cx="12" cy="19" r="1.75" />
</svg>
</button>
{projectMenuOpenId === project.id ? (
<div
ref={projectMenuPopoverRef}
className="project-sidebar__proj-menu-popover"
role="menu"
aria-label={`${project.name} actions`}
>
<button
type="button"
className="project-sidebar__proj-menu-item"
role="menuitem"
onClick={() => {
setProjectMenuOpenId(null);
setProjectSettingsProjectId(project.id);
}}
>
Project settings
</button>
<button
type="button"
className="project-sidebar__proj-menu-item project-sidebar__proj-menu-item--danger"
role="menuitem"
onClick={() => void handleRemoveProject(project)}
disabled={deletingProjectId === project.id}
>
{deletingProjectId === project.id ? "Removing..." : "Remove project"}
</button>
</div>
) : null}
</div>
</div>
);
})}
</div>
<div className="project-sidebar__footer">
<div className="flex items-center gap-1 border-t border-[var(--color-border-subtle)] px-2 py-2">
{/* Show killed toggle */}
{isDegraded ? (
<div className="project-sidebar__degraded-note">Config needs repair</div>
) : null}
{/* Sessions */}
{!isDegraded && isExpanded && (
<div className="project-sidebar__sessions">
{isLoading ? (
<div className="space-y-2 px-3 py-2" aria-label="Loading sessions">
{Array.from({ length: 3 }, (_, index) => (
<div
key={`${project.id}-loading-${index}`}
className="flex items-center gap-3 border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] px-2 py-2"
>
<div className="h-2 w-2 shrink-0 animate-pulse bg-[var(--color-border-strong)]" />
<div className="h-3 flex-1 animate-pulse bg-[var(--color-bg-primary)]" />
<div className="h-3 w-12 animate-pulse bg-[var(--color-bg-primary)]" />
</div>
))}
</div>
) : visibleSessions.length > 0 ? (
visibleSessions.map((session) => {
const level = getAttentionLevel(session);
const isSessionActive = activeSessionId === session.id;
const title = session.branch ?? getSessionTitle(session);
const sessionHref = projectSessionPath(project.id, session.id);
return (
<a
key={session.id}
href={sessionHref}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
navigate(sessionHref, session);
}}
className={cn(
"project-sidebar__sess-row",
isSessionActive && "project-sidebar__sess-row--active",
)}
aria-current={isSessionActive ? "page" : undefined}
aria-label={`Open ${title}`}
>
<SessionDot level={level} />
<div className="flex-1 min-w-0">
<span
className={cn(
"project-sidebar__sess-label",
isSessionActive && "project-sidebar__sess-label--active",
)}
>
{title}
</span>
{showSessionId ? (
<div className="project-sidebar__sess-meta">
<span className="project-sidebar__sess-id">{session.id}</span>
<span className="project-sidebar__sess-status">
{LEVEL_LABELS[level]}
</span>
</div>
) : null}
</div>
{!showSessionId ? (
<span className="project-sidebar__sess-status project-sidebar__sess-status--inline">
{LEVEL_LABELS[level]}
</span>
) : null}
</a>
);
})
) : error ? (
<div className="px-3 py-2">
<div className="project-sidebar__empty">Failed to load sessions</div>
<button
type="button"
className="mt-2 text-xs font-medium text-[var(--color-link)] hover:underline"
onClick={onRetry}
>
Retry
</button>
</div>
) : (
<div className="project-sidebar__empty">No sessions shown</div>
)}
</div>
)}
</div>
);
})}
</div>
<div className="project-sidebar__footer">
<div className="flex items-center gap-1 border-t border-[var(--color-border-subtle)] px-2 py-2">
{/* Show killed toggle */}
<button
type="button"
onClick={() => setShowKilled(!showKilled)}
className={cn(
"project-sidebar__footer-btn",
showKilled && "project-sidebar__footer-btn--active",
)}
aria-pressed={showKilled}
title={showKilled ? "Hide killed sessions" : "Show killed sessions"}
aria-label={showKilled ? "Hide killed sessions" : "Show killed sessions"}
>
{/* skull / terminated icon */}
<svg
width="13"
height="13"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M12 3C7.03 3 3 7.03 3 12c0 3.1 1.5 5.84 3.8 7.55V21h2.4v-1h1.6v1h2.4v-1h1.6v1H17v-1.45A9 9 0 0 0 21 12c0-4.97-4.03-9-9-9z" />
<circle cx="9" cy="11" r="1.5" fill="currentColor" stroke="none" />
<circle cx="15" cy="11" r="1.5" fill="currentColor" stroke="none" />
</svg>
</button>
{/* Show done toggle */}
<button
type="button"
onClick={() => setShowDone(!showDone)}
className={cn(
"project-sidebar__footer-btn",
showDone && "project-sidebar__footer-btn--active",
)}
aria-pressed={showDone}
title={showDone ? "Hide completed sessions" : "Show completed sessions"}
aria-label={showDone ? "Hide completed sessions" : "Show completed sessions"}
>
{/* checkmark / done icon */}
<svg
width="13"
height="13"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</button>
<div className="flex-1" />
{/* Sidebar display settings (gear) */}
<div className="project-sidebar__settings-wrap" ref={settingsRef}>
<button
type="button"
onClick={() => setShowKilled(!showKilled)}
onClick={() => setSettingsOpen((v) => !v)}
className={cn(
"project-sidebar__footer-btn",
showKilled && "project-sidebar__footer-btn--active",
settingsOpen && "project-sidebar__footer-btn--active",
)}
aria-pressed={showKilled}
title={showKilled ? "Hide killed sessions" : "Show killed sessions"}
aria-label={showKilled ? "Hide killed sessions" : "Show killed sessions"}
aria-expanded={settingsOpen}
aria-haspopup="dialog"
title="Sidebar settings"
aria-label="Sidebar settings"
>
{/* skull / terminated icon */}
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3C7.03 3 3 7.03 3 12c0 3.1 1.5 5.84 3.8 7.55V21h2.4v-1h1.6v1h2.4v-1h1.6v1H17v-1.45A9 9 0 0 0 21 12c0-4.97-4.03-9-9-9z" />
<circle cx="9" cy="11" r="1.5" fill="currentColor" stroke="none" />
<circle cx="15" cy="11" r="1.5" fill="currentColor" stroke="none" />
<svg
width="13"
height="13"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
{/* Show done toggle */}
<button
type="button"
onClick={() => setShowDone(!showDone)}
className={cn(
"project-sidebar__footer-btn",
showDone && "project-sidebar__footer-btn--active",
)}
aria-pressed={showDone}
title={showDone ? "Hide completed sessions" : "Show completed sessions"}
aria-label={showDone ? "Hide completed sessions" : "Show completed sessions"}
>
{/* checkmark / done icon */}
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
</button>
<div className="flex-1" />
{/* Sidebar display settings (gear) */}
<div className="project-sidebar__settings-wrap" ref={settingsRef}>
<button
type="button"
onClick={() => setSettingsOpen((v) => !v)}
className={cn(
"project-sidebar__footer-btn",
settingsOpen && "project-sidebar__footer-btn--active",
)}
aria-expanded={settingsOpen}
aria-haspopup="dialog"
title="Sidebar settings"
{settingsOpen ? (
<div
ref={settingsPopoverRef}
className="project-sidebar__settings-popover"
role="dialog"
aria-label="Sidebar settings"
>
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
{settingsOpen ? (
<div
ref={settingsPopoverRef}
className="project-sidebar__settings-popover"
role="dialog"
aria-label="Sidebar settings"
>
<label className="project-sidebar__settings-row">
<input
type="checkbox"
checked={showSessionId}
onChange={(e) => setShowSessionId(e.target.checked)}
/>
<span>Show session ID</span>
</label>
</div>
) : null}
</div>
<ThemeToggle className="project-sidebar__theme-toggle" />
<label className="project-sidebar__settings-row">
<input
type="checkbox"
checked={showSessionId}
onChange={(e) => setShowSessionId(e.target.checked)}
/>
<span>Show session ID</span>
</label>
</div>
) : null}
</div>
<ThemeToggle className="project-sidebar__theme-toggle" />
</div>
<AddProjectModal open={addProjectOpen} onClose={() => setAddProjectOpen(false)} />
<ProjectSettingsModal
open={projectSettingsProjectId !== null}
projectId={projectSettingsProjectId}
onClose={() => setProjectSettingsProjectId(null)}
/>
</div>
<AddProjectModal open={addProjectOpen} onClose={() => setAddProjectOpen(false)} />
<ProjectSettingsModal
open={projectSettingsProjectId !== null}
projectId={projectSettingsProjectId}
onClose={() => setProjectSettingsProjectId(null)}
/>
</aside>
);
}

View File

@ -10,7 +10,6 @@ import {
getSessionTruthLabel,
getPRTruthLabel,
getRuntimeTruthLabel,
getLifecycleGuidance,
isDashboardSessionDone,
isDashboardSessionTerminal,
isDashboardSessionRestorable,
@ -78,7 +77,11 @@ function getDoneStatusInfo(session: DashboardSession): {
};
}
if (session.lifecycle?.sessionState === "terminated" || status === "killed" || status === "terminated") {
if (
session.lifecycle?.sessionState === "terminated" ||
status === "killed" ||
status === "terminated"
) {
return {
label: getSessionTruthLabel(session),
pillClass: "done-status-pill--killed",
@ -190,9 +193,10 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
const title = getSessionTitle(session);
const footerStatus = getFooterStatusLabel(session, level, Boolean(isReadyToMerge));
const visiblePassingChecks = !rateLimited && pr && !prUnenriched
? pr.ciChecks.filter((check) => check.status === "passed").slice(0, 3)
: [];
const visiblePassingChecks =
!rateLimited && pr && !prUnenriched
? pr.ciChecks.filter((check) => check.status === "passed").slice(0, 3)
: [];
const isDone = isDashboardSessionDone(session) || level === "done";
const truthLine = session.lifecycle
? [
@ -201,7 +205,6 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
`Runtime ${getRuntimeTruthLabel(session)}`,
].join(" · ")
: null;
const lifecycleGuidance = getLifecycleGuidance(session);
const secondaryText = session.issueLabel
? `${session.issueLabel}${session.issueTitle ? ` · ${session.issueTitle}` : ""}`
: (session.issueTitle ??
@ -326,6 +329,13 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
</span>
</span>
))}
<a
href={projectSessionPath(session.projectId, session.id)}
onClick={(e) => e.stopPropagation()}
className="done-meta-chip font-[var(--font-mono)] font-semibold text-[var(--color-accent)] no-underline hover:underline"
>
View current context
</a>
</div>
{/* Expandable detail panel */}
@ -473,9 +483,7 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
cardDotTone === "exited" && "card__adot--exited",
)}
/>
<span className="card__id">
{session.id}
</span>
<span className="card__id">{session.id}</span>
<div className="flex-1" />
{isRestorable && (
<button
@ -483,24 +491,30 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
e.stopPropagation();
onRestore?.(session.id);
}}
className="inline-flex items-center gap-1 border border-[color-mix(in_srgb,var(--color-accent)_35%,transparent)] px-2 py-0.5 text-[11px] text-[var(--color-accent)] transition-colors hover:bg-[var(--color-tint-blue)]"
className="session-card__control session-card__restore-control"
>
<svg
className="session-card__control-icon"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
className="h-3 w-3"
>
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
<path d="M20 11a8 8 0 0 0-14.9-3.98" />
<path d="M4 5v4h4" />
<path d="M4 13a8 8 0 0 0 14.9 3.98" />
<path d="M20 19v-4h-4" />
</svg>
restore
</button>
)}
{!isTerminal && (
<a
href={projectSessionHashPath(session.projectId, session.id, "#session-terminal-section")}
href={projectSessionHashPath(
session.projectId,
session.id,
"#session-terminal-section",
)}
onClick={(e) => e.stopPropagation()}
className="session-card__control session-card__terminal-link"
>
@ -522,17 +536,11 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
<div className="session-card__body flex min-h-0 flex-1 flex-col">
<div className="card__title-wrap">
<p className="card__title">
{title}
</p>
<p className="card__title">{title}</p>
</div>
<div className="card__meta">
{session.branch && (
<span className="card__branch">
{session.branch}
</span>
)}
{session.branch && <span className="card__branch">{session.branch}</span>}
{session.branch && pr ? (
<span className="card__meta-sep" aria-hidden="true">
·
@ -569,15 +577,21 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
<div className="px-[10px] pb-[5px]">
{level === "merge" || isReadyToMerge ? (
<p className="session-card__secondary session-card__secondary--merge">
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
<svg
width="9"
height="9"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</svg>
<span>{secondaryText}</span>
</p>
) : (
<p className="session-card__secondary">
{secondaryText}
</p>
<p className="session-card__secondary">{secondaryText}</p>
)}
</div>
)}
@ -590,14 +604,6 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
</div>
)}
{lifecycleGuidance && (
<div className="px-[10px] pb-[6px]">
<p className="inline-flex items-center gap-1 rounded-full border border-[color-mix(in_srgb,var(--color-status-attention)_35%,transparent)] bg-[color-mix(in_srgb,var(--color-status-attention)_9%,transparent)] px-2 py-1 text-[10px] leading-none text-[var(--color-status-attention)]">
{lifecycleGuidance}
</p>
</div>
)}
{rateLimited && pr?.state === "open" && (
<div className="px-[10px] pb-[5px]">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
@ -621,7 +627,15 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
{visiblePassingChecks.map((check) => {
const chipContent = (
<>
<svg width="8" height="8" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24" aria-hidden="true">
<svg
width="8"
height="8"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</svg>
{check.name}
@ -650,10 +664,7 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
{!rateLimited && alerts.length > 0 && (
<div className="card__alerts flex flex-col">
{alerts.slice(0, 3).map((alert) => (
<div
key={alert.key}
className={cn("alert-row", `alert-row--${alert.type}`)}
>
<div key={alert.key} className={cn("alert-row", `alert-row--${alert.type}`)}>
<span className="alert-row__icon">{alert.icon}</span>
<span className="alert-row__text">
<a
@ -671,7 +682,8 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
</a>
{alert.notified && (
<span className="alert-row__notified" title="Agent has been notified">
{" "}&middot; notified
{" "}
&middot; notified
</span>
)}
</span>
@ -700,7 +712,16 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
<div className="quick-reply" onClick={(e) => e.stopPropagation()}>
{session.summary && !session.summaryIsFallback && (
<div className="card__agent-msg">
<svg className="card__agent-msg-icon" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
<svg
className="card__agent-msg-icon"
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>{session.summary}</span>
@ -713,55 +734,61 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
>
View current context
</a>
<div className="card__presets">
<button
className="card__preset"
onClick={() => void handleQuickReply("continue")}
disabled={sendingQuickReply !== null}
>
{sendingQuickReply === "continue"
? "Sending..."
: sentQuickReply === "continue"
? "Sent"
: "Continue"}
</button>
<button
className="card__preset"
onClick={() => void handleQuickReply("abort")}
disabled={sendingQuickReply !== null}
>
{sendingQuickReply === "abort"
? "Sending..."
: sentQuickReply === "abort"
? "Sent"
: "Abort"}
</button>
<button
className="card__preset"
onClick={() => void handleQuickReply("skip")}
disabled={sendingQuickReply !== null}
>
{sendingQuickReply === "skip"
? "Sending..."
: sentQuickReply === "skip"
? "Sent"
: "Skip"}
</button>
</div>
<div className="card__reply-wrap">
<textarea
className="card__reply"
placeholder={sendingQuickReply !== null ? "Sending..." : "Type a reply... (Enter to send)"}
aria-label="Type a reply to the agent"
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={(e) => {
void handleReplyKeyDown(e);
}}
rows={1}
disabled={sendingQuickReply !== null}
/>
</div>
{!isTerminal && (
<>
<div className="card__presets">
<button
className="card__preset"
onClick={() => void handleQuickReply("continue")}
disabled={sendingQuickReply !== null}
>
{sendingQuickReply === "continue"
? "Sending..."
: sentQuickReply === "continue"
? "Sent"
: "Continue"}
</button>
<button
className="card__preset"
onClick={() => void handleQuickReply("abort")}
disabled={sendingQuickReply !== null}
>
{sendingQuickReply === "abort"
? "Sending..."
: sentQuickReply === "abort"
? "Sent"
: "Abort"}
</button>
<button
className="card__preset"
onClick={() => void handleQuickReply("skip")}
disabled={sendingQuickReply !== null}
>
{sendingQuickReply === "skip"
? "Sending..."
: sentQuickReply === "skip"
? "Sent"
: "Skip"}
</button>
</div>
<div className="card__reply-wrap">
<textarea
className="card__reply"
placeholder={
sendingQuickReply !== null ? "Sending..." : "Type a reply... (Enter to send)"
}
aria-label="Type a reply to the agent"
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={(e) => {
void handleReplyKeyDown(e);
}}
rows={1}
disabled={sendingQuickReply !== null}
/>
</div>
</>
)}
</div>
)}
@ -809,7 +836,9 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
)}
>
{killConfirming ? (
<span className="font-mono text-[10px] font-semibold tracking-[0.04em]">kill?</span>
<span className="font-mono text-[10px] font-semibold tracking-[0.04em]">
kill?
</span>
) : (
<svg
className="session-card__control-icon"
@ -852,7 +881,8 @@ function getFooterStatusLabel(
if (isReadyToMerge || level === "merge") return "mergeable";
if (session.lifecycle?.sessionState === "detecting") return "detecting";
if (level === "respond") return getSessionTruthLabel(session);
if (session.lifecycle?.prReason === "ci_failing" || session.status === "ci_failed") return "ci failing";
if (session.lifecycle?.prReason === "ci_failing" || session.status === "ci_failed")
return "ci failing";
if (level === "review") return getPRTruthLabel(session);
if (level === "working") return getSessionTruthLabel(session);
return getSessionTruthLabel(session);
@ -949,7 +979,15 @@ function getAlerts(session: DashboardSession): Alert[] {
key: "review",
type: "review",
icon: (
<svg width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
<svg
width="9"
height="9"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />

View File

@ -15,7 +15,7 @@ import {
import { CI_STATUS } from "@aoagents/ao-core/types";
import { cn } from "@/lib/cn";
import dynamic from "next/dynamic";
import { getSessionTitle } from "@/lib/format";
import { formatRelativeTime, getSessionTitle } from "@/lib/format";
import { buildGitHubCompareUrl } from "@/lib/github-links";
import type { ProjectInfo } from "@/lib/project-name";
import { SidebarContext } from "./workspace/SidebarContext";
@ -59,7 +59,6 @@ interface SessionDetailProps {
// ── Helpers ──────────────────────────────────────────────────────────
const activityMeta: Record<string, { label: string; color: string }> = {
active: { label: "Active", color: "var(--color-status-working)" },
ready: { label: "Ready", color: "var(--color-status-ready)" },
@ -91,29 +90,153 @@ function normalizeActivityLabelForClass(activityLabel: string): string {
return activityLabel.toLowerCase().replace(/\s+/g, "-");
}
/**
* Compact agent-zone breakdown for orchestrator sessions, rendered inline in
* the topbar pill row. Replaces the previous stacked status strip that sat
* above the terminal.
*/
function formatEndedTime(isoDate: string | null | undefined): string {
if (!isoDate) return "Unknown";
const timestamp = new Date(isoDate).getTime();
if (!Number.isFinite(timestamp)) return "Unknown";
return formatRelativeTime(timestamp);
}
function getEndedSessionReason(session: DashboardSession): string {
if (session.lifecycle?.runtime.reasonLabel) {
return session.lifecycle.runtime.reasonLabel;
}
if (session.status === "killed") return "Manually stopped";
if (session.status === "terminated") return "Runtime unavailable";
if (session.status === "done" || session.status === "merged") return "Work completed";
return "Terminal ended";
}
function getEndedSessionSummary(session: DashboardSession, headline: string): string {
const pinnedSummary = session.metadata["pinnedSummary"];
if (pinnedSummary) return pinnedSummary;
if (session.summary && !session.summaryIsFallback) return session.summary;
if (session.lifecycle?.summary) return session.lifecycle.summary;
if (session.userPrompt) return session.userPrompt;
if (session.summary) return session.summary;
return headline;
}
function SessionEndedSummary({
session,
headline,
pr,
dashboardHref,
}: {
session: DashboardSession;
headline: string;
pr: DashboardPR | null;
dashboardHref: string;
}) {
const reason = getEndedSessionReason(session);
const summary = getEndedSessionSummary(session, headline);
const endedAt =
session.lifecycle?.session.terminatedAt ??
session.lifecycle?.session.completedAt ??
session.lifecycle?.session.lastTransitionAt ??
session.lastActivityAt;
const runtimeLabel = session.lifecycle?.runtime.label ?? "Unavailable";
const prLabel = pr
? pr.state === "merged"
? "Merged"
: pr.state === "closed"
? "Closed"
: pr.mergeability.mergeable
? "Open, merge-ready"
: "Open"
: "No PR";
return (
<section className="session-ended-summary" aria-label="Session ended summary">
<div className="session-ended-summary__panel">
<div className="session-ended-summary__eyebrow">Terminal ended</div>
<div className="session-ended-summary__header">
<div className="session-ended-summary__icon" aria-hidden="true">
<svg fill="none" stroke="currentColor" strokeWidth="1.8" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="3" />
<path d="M7 10l3 2-3 2" />
<path d="M13 15h4" />
</svg>
</div>
<div className="session-ended-summary__title-group">
<h2 className="session-ended-summary__title">{headline}</h2>
<p className="session-ended-summary__subtitle">
{reason}. The live terminal is gone, but the session context is still available.
</p>
</div>
</div>
<div className="session-ended-summary__body">
<div className="session-ended-summary__section">
<div className="session-ended-summary__label">What happened</div>
<p className="session-ended-summary__copy">{summary}</p>
</div>
<div className="session-ended-summary__facts" aria-label="Session facts">
<div className="session-ended-summary__fact">
<span>Session</span>
<strong>{session.id}</strong>
</div>
<div className="session-ended-summary__fact">
<span>Ended</span>
<strong>{formatEndedTime(endedAt)}</strong>
</div>
<div className="session-ended-summary__fact">
<span>Runtime</span>
<strong>{runtimeLabel}</strong>
</div>
<div className="session-ended-summary__fact">
<span>PR</span>
<strong>{prLabel}</strong>
</div>
</div>
<div className="session-ended-summary__links">
{pr ? (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="session-ended-summary__primary"
>
Open PR #{pr.number}
</a>
) : null}
<a href={dashboardHref} className="session-ended-summary__secondary">
Back to dashboard
</a>
</div>
{session.lifecycle?.evidence ? (
<div className="session-ended-summary__evidence">
<span>Evidence</span>
<code>{session.lifecycle.evidence}</code>
</div>
) : null}
</div>
</div>
</section>
);
}
function OrchestratorZonePills({ zones }: { zones: OrchestratorZones }) {
const stats: Array<{ value: number; label: string; toneClass: string }> = [
{ value: zones.merge, label: "merge", toneClass: "topbar-zone-pill--merge" },
{ value: zones.merge, label: "merge", toneClass: "topbar-zone-pill--merge" },
{ value: zones.respond, label: "respond", toneClass: "topbar-zone-pill--respond" },
{ value: zones.review, label: "review", toneClass: "topbar-zone-pill--review" },
{ value: zones.review, label: "review", toneClass: "topbar-zone-pill--review" },
{ value: zones.working, label: "working", toneClass: "topbar-zone-pill--working" },
{ value: zones.pending, label: "pending", toneClass: "topbar-zone-pill--pending" },
{ value: zones.done, label: "done", toneClass: "topbar-zone-pill--done" },
].filter((s) => s.value > 0);
{ value: zones.done, label: "done", toneClass: "topbar-zone-pill--done" },
].filter((stat) => stat.value > 0);
if (stats.length === 0) return null;
return (
<>
{stats.map((s) => (
<span key={s.label} className={cn("topbar-zone-pill", s.toneClass)}>
<span className="topbar-zone-pill__value">{s.value}</span>
<span className="topbar-zone-pill__label">{s.label}</span>
{stats.map((stat) => (
<span key={stat.label} className={cn("topbar-zone-pill", stat.toneClass)}>
<span className="topbar-zone-pill__value">{stat.value}</span>
<span className="topbar-zone-pill__label">{stat.label}</span>
</span>
))}
</>
@ -186,7 +309,9 @@ export function SessionDetail({
const handleKill = useCallback(async () => {
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(session.id)}/kill`, { method: "POST" });
const res = await fetch(`/api/sessions/${encodeURIComponent(session.id)}/kill`, {
method: "POST",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (projectOrchestratorId) {
router.push(projectSessionPath(session.projectId, projectOrchestratorId));
@ -200,8 +325,13 @@ export function SessionDetail({
const handleRestore = useCallback(async () => {
try {
const res = await fetch(`/api/sessions/${encodeURIComponent(session.id)}/restore`, { method: "POST" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const res = await fetch(`/api/sessions/${encodeURIComponent(session.id)}/restore`, {
method: "POST",
});
if (!res.ok) {
const message = await res.text().catch(() => "");
throw new Error(message || `HTTP ${res.status}`);
}
window.location.reload();
} catch (err) {
console.error("Failed to restore session:", err);
@ -232,12 +362,11 @@ export function SessionDetail({
const headerProjectLabel =
projects.find((project) => project.id === session.projectId)?.name ?? session.projectId;
const showHeaderProjectLabel =
headerProjectLabel.trim().toLowerCase() !== "agent orchestrator";
const showHeaderProjectLabel = headerProjectLabel.trim().toLowerCase() !== "agent orchestrator";
const orchestratorHref = useMemo(() => {
if (isOrchestrator) return projectSessionPath(session.projectId, session.id);
if (!projectOrchestratorId) return null;
return projectSessionPath(session.projectId, projectOrchestratorId);
if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId);
return null;
}, [isOrchestrator, projectOrchestratorId, session.id, session.projectId]);
useEffect(() => {
@ -258,238 +387,309 @@ export function SessionDetail({
return (
<SidebarContext.Provider value={{ onToggleSidebar: handleToggleSidebar, mobileSidebarOpen }}>
<div className="dashboard-app-shell">
<header className="dashboard-app-header">
{projects.length > 0 ? (
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={handleToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
<svg width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
) : (
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
)}
</button>
) : null}
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
<span>Agent Orchestrator</span>
</div>
{/* Desktop sep (hidden on mobile since brand is hidden) */}
{showHeaderProjectLabel && (
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
)}
{/* Project name + pills: stacked column on mobile, inline on desktop.
<div className="dashboard-app-shell">
<header className="dashboard-app-header">
{projects.length > 0 ? (
<button
type="button"
className="dashboard-app-sidebar-toggle"
onClick={handleToggleSidebar}
aria-label="Toggle sidebar"
>
{isMobile ? (
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
) : (
<svg
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
viewBox="0 0 24 24"
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
)}
</button>
) : null}
<div className="dashboard-app-header__brand dashboard-app-header__brand--hide-mobile">
<span>Agent Orchestrator</span>
</div>
{/* Desktop sep (hidden on mobile since brand is hidden) */}
{showHeaderProjectLabel && (
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
)}
{/* Project name + pills: stacked column on mobile, inline on desktop.
On mobile the project name + session id share line 1 (so ao-N stays
visually bound to the project), pills stack below on line 2. */}
<div className="topbar-project-pills-group">
<div className="topbar-project-line">
{showHeaderProjectLabel && (
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
)}
<span className="dashboard-app-header__session-id topbar-mobile-only">
{session.id}
</span>
{isOrchestrator && (
<span className="session-detail-mode-badge">orchestrator</span>
)}
</div>
<div className="topbar-session-pills">
<div className={cn("topbar-status-pill", `topbar-status-pill--${normalizeActivityLabelForClass(activity.label)}`)}>
<span className="topbar-status-pill__dot" style={{ background: activity.color }} />
<span className="topbar-status-pill__label">{activity.label}</span>
</div>
{session.branch ? (
pr ? (
<a
href={buildGitHubBranchUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="topbar-branch-pill topbar-branch-pill--link"
>
{session.branch}
</a>
) : (
<span className="topbar-branch-pill">{session.branch}</span>
)
) : null}
{isOrchestrator && orchestratorZones ? (
<OrchestratorZonePills zones={orchestratorZones} />
) : null}
</div>
</div>
{/* Desktop-only session title + session id.
On mobile the session id lives next to the project name (above). */}
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
<span className="dashboard-app-header__session-title topbar-desktop-only">{headline}</span>
<span className="dashboard-app-header__session-id topbar-desktop-only">{session.id}</span>
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
{pr ? (
<div className="topbar-pr-btn-wrap" ref={prPopoverRef}>
{/* Anchored to the actual PR URL so ctrl/cmd-click opens the PR on
GitHub in a new tab. Plain click toggles the details popover. */}
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className={cn("dashboard-app-btn topbar-pr-btn", prPopoverOpen && "topbar-pr-btn--open")}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
setPrPopoverOpen((v) => !v);
}}
aria-expanded={prPopoverOpen}
aria-label={`PR #${pr.number}`}
>
<span className={cn(
"topbar-pr-dot",
allGreen
? "topbar-pr-dot--green"
: (pr.ciStatus === CI_STATUS.FAILING || pr.reviewDecision === "changes_requested")
? "topbar-pr-dot--red"
: "topbar-pr-dot--amber",
)} />
PR #{pr.number}
<svg width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5"
viewBox="0 0 24 24" aria-hidden="true">
<path d={prPopoverOpen ? "M18 15l-6-6-6 6" : "M6 9l6 6 6-6"} />
</svg>
</a>
{prPopoverOpen && (
<div className="topbar-pr-popover">
<SessionDetailPRCard pr={pr} sessionId={session.id} metadata={session.metadata} />
</div>
<div className="topbar-project-pills-group">
<div className="topbar-project-line">
{showHeaderProjectLabel && (
<span className="dashboard-app-header__project">{headerProjectLabel}</span>
)}
<span className="dashboard-app-header__session-id topbar-mobile-only">
{session.id}
</span>
{isOrchestrator && (
<span className="session-detail-mode-badge">orchestrator</span>
)}
</div>
) : null}
<div className="topbar-session-pills">
<div
className={cn(
"topbar-status-pill",
`topbar-status-pill--${normalizeActivityLabelForClass(activity.label)}`,
)}
>
<span className="topbar-status-pill__dot" style={{ background: activity.color }} />
<span className="topbar-status-pill__label">{activity.label}</span>
</div>
{session.branch ? (
pr ? (
<a
href={buildGitHubBranchUrl(pr)}
target="_blank"
rel="noopener noreferrer"
className="topbar-branch-pill topbar-branch-pill--link"
>
{session.branch}
</a>
) : (
<span className="topbar-branch-pill">{session.branch}</span>
)
) : null}
{isOrchestrator && orchestratorZones ? (
<OrchestratorZonePills zones={orchestratorZones} />
) : null}
</div>
</div>
{/* Desktop-only session title + session id.
On mobile the session id lives next to the project name (above). */}
<span className="dashboard-app-header__sep topbar-desktop-only" aria-hidden="true" />
<span className="dashboard-app-header__session-title topbar-desktop-only">
{headline}
</span>
<span className="dashboard-app-header__session-id topbar-desktop-only">
{session.id}
</span>
<div className="dashboard-app-header__spacer" />
<div className="dashboard-app-header__actions">
{pr ? (
<div className="topbar-pr-btn-wrap" ref={prPopoverRef}>
{/* Anchored to the actual PR URL so ctrl/cmd-click opens the PR on
GitHub in a new tab. Plain click toggles the details popover. */}
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"dashboard-app-btn topbar-pr-btn",
prPopoverOpen && "topbar-pr-btn--open",
)}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
setPrPopoverOpen((v) => !v);
}}
aria-expanded={prPopoverOpen}
aria-label={`PR #${pr.number}`}
>
<span
className={cn(
"topbar-pr-dot",
allGreen
? "topbar-pr-dot--green"
: pr.ciStatus === CI_STATUS.FAILING ||
pr.reviewDecision === "changes_requested"
? "topbar-pr-dot--red"
: "topbar-pr-dot--amber",
)}
/>
PR #{pr.number}
<svg
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d={prPopoverOpen ? "M18 15l-6-6-6 6" : "M6 9l6 6 6-6"} />
</svg>
</a>
{/* Restore is available for any restorable session; Kill stays worker-only. */}
{isRestorable ? (
<button type="button" className="dashboard-app-btn" onClick={handleRestore}>
<svg className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
<span className="topbar-btn-label">Restore</span>
</button>
) : !isOrchestrator && !terminalEnded ? (
<button type="button" className="dashboard-app-btn dashboard-app-btn--danger" onClick={handleKill}>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
{prPopoverOpen && (
<div className="topbar-pr-popover">
<SessionDetailPRCard
pr={pr}
sessionId={session.id}
metadata={session.metadata}
/>
</div>
)}
</div>
) : null}
{/* Restore is available for any restorable session; Kill stays worker-only. */}
{isRestorable ? (
<button
type="button"
className="dashboard-app-btn dashboard-app-btn--restore"
onClick={handleRestore}
>
<svg
className="topbar-action-icon"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M20 11a8 8 0 0 0-14.9-3.98" />
<path d="M4 5v4h4" />
<path d="M4 13a8 8 0 0 0 14.9 3.98" />
<path d="M20 19v-4h-4" />
</svg>
<span className="topbar-btn-label">Restore</span>
</button>
) : !isOrchestrator && !terminalEnded ? (
<button
type="button"
className="dashboard-app-btn dashboard-app-btn--danger"
onClick={handleKill}
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
<span className="topbar-btn-label">Kill</span>
</button>
) : null}
) : null}
{!isOrchestrator && orchestratorHref ? (
<a
href={orchestratorHref}
className="dashboard-app-btn dashboard-app-btn--amber"
aria-label="Orchestrator"
>
<svg
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
viewBox="0 0 24 24"
aria-hidden="true"
{orchestratorHref ? (
<a
href={orchestratorHref}
className="dashboard-app-btn dashboard-app-btn--amber topbar-desktop-only"
aria-label="Orchestrator"
>
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" />
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
</svg>
<span className="topbar-btn-label">Orchestrator</span>
</a>
) : null}
</div>
</header>
<div
className={`dashboard-shell dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
>
{projects.length > 0 ? (
<div className={`sidebar-wrapper${mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""}`}>
<ProjectSidebar
projects={projects}
sessions={sidebarSessions}
loading={sidebarLoading}
error={sidebarError}
onRetry={onRetrySidebar}
activeProjectId={session.projectId}
activeSessionId={session.id}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onMobileClose={() => setMobileSidebarOpen(false)}
/>
<svg
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="5" r="2" fill="currentColor" stroke="none" />
<path d="M12 7v4M12 11H6M12 11h6M6 11v3M12 11v3M18 11v3" />
<circle cx="6" cy="17" r="2" />
<circle cx="12" cy="17" r="2" />
<circle cx="18" cy="17" r="2" />
</svg>
<span className="topbar-btn-label">Orchestrator</span>
</a>
) : null}
</div>
) : null}
{mobileSidebarOpen && (
<div className="sidebar-mobile-backdrop" onClick={() => setMobileSidebarOpen(false)} />
)}
</header>
<div className="dashboard-main dashboard-main--desktop">
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
{/* Terminal — fills all remaining height */}
<div className="flex-1 min-h-0 flex flex-col">
{!showTerminal ? (
<div className="session-detail-terminal-placeholder h-full" />
) : terminalEnded ? (
<div className="terminal-exited-placeholder h-full">
<span className="terminal-exited-placeholder__text">Terminal session has ended</span>
</div>
) : (
<DirectTerminal
sessionId={session.id}
startFullscreen={startFullscreen}
variant={terminalVariant}
appearance="dark"
height="100%"
isOpenCodeSession={isOpenCodeSession}
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
autoFocus
/>
)}
<div
className={`dashboard-shell dashboard-shell--desktop${sidebarCollapsed ? " dashboard-shell--sidebar-collapsed" : ""}`}
>
{projects.length > 0 ? (
<div
className={`sidebar-wrapper${mobileSidebarOpen ? " sidebar-wrapper--mobile-open" : ""}`}
>
<ProjectSidebar
projects={projects}
sessions={sidebarSessions}
loading={sidebarLoading}
error={sidebarError}
onRetry={onRetrySidebar}
activeProjectId={session.projectId}
activeSessionId={session.id}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((current) => !current)}
onMobileClose={() => setMobileSidebarOpen(false)}
/>
</div>
</main>
) : null}
{mobileSidebarOpen && (
<div className="sidebar-mobile-backdrop" onClick={() => setMobileSidebarOpen(false)} />
)}
<div className="dashboard-main dashboard-main--desktop">
<main className="session-detail-page flex-1 min-h-0 flex flex-col bg-[var(--color-bg-base)]">
{/* Terminal — fills all remaining height */}
<div className="flex-1 min-h-0 flex flex-col">
{!showTerminal ? (
<div className="session-detail-terminal-placeholder h-full" />
) : terminalEnded ? (
<SessionEndedSummary
session={session}
headline={headline}
pr={pr}
dashboardHref={dashboardHref}
/>
) : (
<DirectTerminal
sessionId={session.id}
startFullscreen={startFullscreen}
variant={terminalVariant}
appearance="dark"
height="100%"
isOpenCodeSession={isOpenCodeSession}
reloadCommand={isOpenCodeSession ? reloadCommand : undefined}
autoFocus
/>
)}
</div>
</main>
</div>
</div>
<MobileBottomNav
ariaLabel="Session navigation"
activeTab={isOrchestrator ? "orchestrator" : undefined}
dashboardHref={dashboardHref}
prsHref={
session.projectId ? `/?project=${encodeURIComponent(session.projectId)}&tab=prs` : "/"
}
showOrchestrator={!!orchestratorHref}
orchestratorHref={orchestratorHref}
/>
</div>
<MobileBottomNav
ariaLabel="Session navigation"
activeTab={isOrchestrator ? "orchestrator" : undefined}
dashboardHref={dashboardHref}
prsHref={session.projectId ? `/?project=${encodeURIComponent(session.projectId)}&tab=prs` : "/"}
showOrchestrator={!!orchestratorHref}
orchestratorHref={orchestratorHref}
/>
</div>
</SidebarContext.Provider>
);
}
// ── Session detail PR card ────────────────────────────────────────────
function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; sessionId: string; metadata: Record<string, string> }) {
function SessionDetailPRCard({
pr,
sessionId,
metadata,
}: {
pr: DashboardPR;
sessionId: string;
metadata: Record<string, string>;
}) {
const [sendingComments, setSendingComments] = useState<Set<string>>(new Set());
const [sentComments, setSentComments] = useState<Set<string>>(new Set());
const [errorComments, setErrorComments] = useState<Set<string>>(new Set());
@ -565,7 +765,8 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
const fileCount = pr.changedFiles ?? 0;
const mergeabilityReliable = !isPRUnenriched(pr) && !isPRRateLimited(pr);
const hasConflicts = mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
const hasConflicts =
mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
const showConflictActions = hasConflicts && pr.state === "open";
const compareUrl = showConflictActions ? buildGitHubCompareUrl(pr) : "";
@ -611,9 +812,7 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
{fileCount} file{fileCount !== 1 ? "s" : ""}
</span>
)}
{pr.isDraft && (
<span className="session-detail-pr-card__diff-label">Draft</span>
)}
{pr.isDraft && <span className="session-detail-pr-card__diff-label">Draft</span>}
{pr.state === "merged" && (
<span className="session-detail-pr-card__diff-label">Merged</span>
)}
@ -691,10 +890,19 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
check.status === "passed" && "session-detail-ci-chip--pass",
check.status === "failed" && "session-detail-ci-chip--fail",
check.status === "pending" && "session-detail-ci-chip--pending",
check.status !== "passed" && check.status !== "failed" && check.status !== "pending" && "session-detail-ci-chip--queued",
check.status !== "passed" &&
check.status !== "failed" &&
check.status !== "pending" &&
"session-detail-ci-chip--queued",
)}
>
{check.status === "passed" ? "\u2713" : check.status === "failed" ? "\u2717" : check.status === "pending" ? "\u25CF" : "\u25CB"}{" "}
{check.status === "passed"
? "\u2713"
: check.status === "failed"
? "\u2717"
: check.status === "pending"
? "\u25CF"
: "\u25CB"}{" "}
{check.name}
</span>
);
@ -817,14 +1025,16 @@ function buildBlockerChips(pr: DashboardPR, metadata: Record<string, string>): B
const hasChangesRequested =
pr.reviewDecision === "changes_requested" || lifecycleStatus === "changes_requested";
const mergeabilityReliable = !isPRUnenriched(pr) && !isPRRateLimited(pr);
const hasConflicts = mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
const hasConflicts =
mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
if (ciIsFailing) {
const failCount = pr.ciChecks.filter((c) => c.status === "failed").length;
chips.push({
icon: "\u2717",
variant: "fail",
text: failCount > 0 ? `${failCount} check${failCount !== 1 ? "s" : ""} failing` : "CI failing",
text:
failCount > 0 ? `${failCount} check${failCount !== 1 ? "s" : ""} failing` : "CI failing",
notified: ciNotified,
});
} else if (pr.ciStatus === CI_STATUS.PENDING) {
@ -832,13 +1042,23 @@ function buildBlockerChips(pr: DashboardPR, metadata: Record<string, string>): B
}
if (hasChangesRequested) {
chips.push({ icon: "\u2717", variant: "fail", text: "Changes requested", notified: reviewNotified });
chips.push({
icon: "\u2717",
variant: "fail",
text: "Changes requested",
notified: reviewNotified,
});
} else if (!pr.mergeability.approved) {
chips.push({ icon: "\u25CB", variant: "muted", text: "Awaiting reviewer" });
}
if (hasConflicts) {
chips.push({ icon: "\u2717", variant: "fail", text: "Merge conflicts", notified: conflictNotified });
chips.push({
icon: "\u2717",
variant: "fail",
text: "Merge conflicts",
notified: conflictNotified,
});
}
if (pr.isDraft) {

View File

@ -3,6 +3,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { Dashboard } from "../Dashboard";
const eventSourceConstructorMock = vi.hoisted(() => vi.fn());
let lastEventSourceMock: {
onmessage: ((event: MessageEvent) => void) | null;
onerror: ((event?: Event) => void) | null;
close: ReturnType<typeof vi.fn>;
};
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
@ -16,6 +21,7 @@ beforeEach(() => {
onerror: null,
close: vi.fn(),
};
lastEventSourceMock = eventSourceMock;
eventSourceConstructorMock.mockReset();
eventSourceConstructorMock.mockImplementation(() => eventSourceMock as unknown as EventSource);
global.EventSource = Object.assign(eventSourceConstructorMock, {
@ -110,6 +116,35 @@ describe("Dashboard empty state", () => {
expect(eventSourceConstructorMock).toHaveBeenCalledTimes(1);
});
it("shows a live load error banner when the SSE stream reports a backend failure", async () => {
render(<Dashboard initialSessions={[]} />);
await waitFor(() => {
lastEventSourceMock.onmessage?.({
data: JSON.stringify({ type: "error", error: "session list timed out" }),
} as MessageEvent);
expect(screen.getByRole("alert")).toHaveTextContent("session list timed out");
});
});
it("clears a live load error banner after a healthy snapshot with unchanged sessions", async () => {
render(<Dashboard initialSessions={[]} />);
await waitFor(() => {
lastEventSourceMock.onmessage?.({
data: JSON.stringify({ type: "error", error: "session list timed out" }),
} as MessageEvent);
expect(screen.getByRole("alert")).toHaveTextContent("session list timed out");
});
await waitFor(() => {
lastEventSourceMock.onmessage?.({
data: JSON.stringify({ type: "snapshot", sessions: [] }),
} as MessageEvent);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
it("shows empty state when only done sessions exist", () => {
render(
<Dashboard

View File

@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { Dashboard } from "@/components/Dashboard";
import { makePR, makeSession } from "@/__tests__/helpers";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
describe("Dashboard kanban layout", () => {
beforeEach(() => {
global.EventSource = vi.fn(
() =>
({
onmessage: null,
onerror: null,
close: vi.fn(),
}) as unknown as EventSource,
);
global.fetch = vi.fn();
});
it("uses four board columns in simple attention mode", () => {
render(
<Dashboard
initialSessions={[
makeSession({
id: "respond-1",
status: "waiting_input",
activity: "waiting_input",
summary: "Needs a reply",
}),
]}
/>,
);
const board = document.querySelector(".kanban-board");
expect(board).toHaveAttribute("data-columns", "4");
expect(board).toHaveStyle({ "--kanban-column-count": "4" });
expect(screen.getByText("Action")).toBeInTheDocument();
expect(screen.queryByText("Respond")).not.toBeInTheDocument();
});
it("uses five board columns in detailed attention mode", () => {
render(
<Dashboard
initialSessions={[
makeSession({
id: "review-1",
status: "reviewing",
pr: makePR({
number: 42,
reviewDecision: "changes_requested",
}),
}),
]}
attentionZones="detailed"
/>,
);
const board = document.querySelector(".kanban-board");
expect(board).toHaveAttribute("data-columns", "5");
expect(board).toHaveStyle({ "--kanban-column-count": "5" });
expect(screen.getByText("Respond")).toBeInTheDocument();
});
});

View File

@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { Dashboard } from "../Dashboard";
import { makePR, makeSession } from "../../__tests__/helpers";
const refreshMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: refreshMock }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
@ -27,6 +29,7 @@ function mockMobileViewport() {
describe("Dashboard unified layout (mobile viewport)", () => {
beforeEach(() => {
refreshMock.mockReset();
mockMobileViewport();
Element.prototype.scrollIntoView = vi.fn();
const eventSourceMock = {
@ -70,10 +73,7 @@ describe("Dashboard unified layout (mobile viewport)", () => {
it("shows hamburger toggle button in topbar on mobile", () => {
render(
<Dashboard
initialSessions={[makeSession()]}
projects={[{ id: "my-app", name: "My App" }]}
/>,
<Dashboard initialSessions={[makeSession()]} projects={[{ id: "my-app", name: "My App" }]} />,
);
expect(screen.getByLabelText("Toggle sidebar")).toBeInTheDocument();
@ -96,7 +96,9 @@ describe("Dashboard unified layout (mobile viewport)", () => {
it("shows PRs link in header pointing to PR page", () => {
render(
<Dashboard
initialSessions={[makeSession({ id: "merge-2", status: "approved", pr: makePR({ number: 91 }) })]}
initialSessions={[
makeSession({ id: "merge-2", status: "approved", pr: makePR({ number: 91 }) }),
]}
projectId="my-app"
/>,
);
@ -216,6 +218,7 @@ describe("Dashboard unified layout (mobile viewport)", () => {
expect(global.fetch).toHaveBeenCalledWith("/api/sessions/done-1/restore", {
method: "POST",
});
expect(refreshMock).toHaveBeenCalledTimes(1);
});
it("kill button requires a two-click confirmation before firing", async () => {
@ -243,10 +246,7 @@ describe("Dashboard unified layout (mobile viewport)", () => {
// First click → enters confirming state; does not fire the kill request
fireEvent.click(killButtons[0]);
expect(fetchSpy).not.toHaveBeenCalledWith(
expect.stringContaining("/kill"),
expect.anything(),
);
expect(fetchSpy).not.toHaveBeenCalledWith(expect.stringContaining("/kill"), expect.anything());
// Button now advertises the confirm affordance
const confirm = screen.getByRole("button", { name: "Confirm terminate session" });

View File

@ -9,7 +9,7 @@ vi.mock("next/navigation", () => ({
const mockOrchestrators = [
{
id: "app-orchestrator-1",
id: "app-orchestrator",
projectId: "my-project",
projectName: "My Project",
status: "working",
@ -17,15 +17,6 @@ const mockOrchestrators = [
createdAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
lastActivityAt: new Date(Date.now() - 300000).toISOString(), // 5 min ago
},
{
id: "app-orchestrator-2",
projectId: "my-project",
projectName: "My Project",
status: "spawning",
activity: null,
createdAt: new Date(Date.now() - 7200000).toISOString(), // 2 hours ago
lastActivityAt: null,
},
];
const defaultProps = {
@ -45,43 +36,37 @@ describe("OrchestratorSelector", () => {
it("renders orchestrator list", () => {
render(<OrchestratorSelector {...defaultProps} />);
expect(screen.getByText("app-orchestrator-1")).toBeInTheDocument();
expect(screen.getByText("app-orchestrator-2")).toBeInTheDocument();
expect(screen.getByText("app-orchestrator")).toBeInTheDocument();
});
it("displays project name in header", () => {
render(<OrchestratorSelector {...defaultProps} />);
expect(screen.getByText("My Project")).toBeInTheDocument();
expect(screen.getByText("Select an orchestrator")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute("href", "/projects/my-project");
expect(screen.getByText("Project orchestrator")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute(
"href",
"/projects/my-project",
);
});
it("shows count of existing sessions", () => {
it("explains that orchestrator opening reuses the canonical session", () => {
render(<OrchestratorSelector {...defaultProps} />);
expect(screen.getByText(/existing orchestrator sessions/)).toBeInTheDocument();
// The count "2" appears in multiple places, so we check the full info banner text
expect(screen.getByText(/Found/)).toBeInTheDocument();
expect(screen.getByText(/one main orchestrator per project/i)).toBeInTheDocument();
});
it("shows error state", () => {
render(
<OrchestratorSelector
{...defaultProps}
orchestrators={[]}
error="Project not found"
/>,
);
render(<OrchestratorSelector {...defaultProps} orchestrators={[]} error="Project not found" />);
expect(screen.getByText("Error")).toBeInTheDocument();
expect(screen.getByText("Project not found")).toBeInTheDocument();
});
it("shows start new orchestrator button", () => {
it("shows open orchestrator button", () => {
render(<OrchestratorSelector {...defaultProps} />);
expect(screen.getByRole("button", { name: /start new orchestrator/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /open orchestrator/i })).toBeInTheDocument();
});
it("spawns new orchestrator on button click and navigates", async () => {
@ -89,14 +74,14 @@ describe("OrchestratorSelector", () => {
ok: true,
json: () =>
Promise.resolve({
orchestrator: { id: "app-orchestrator-3" },
orchestrator: { id: "app-orchestrator" },
}),
});
global.fetch = mockFetch;
render(<OrchestratorSelector {...defaultProps} />);
const button = screen.getByRole("button", { name: /start new orchestrator/i });
const button = screen.getByRole("button", { name: /open orchestrator/i });
fireEvent.click(button);
await waitFor(() => {
@ -104,7 +89,7 @@ describe("OrchestratorSelector", () => {
});
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/projects/my-project/sessions/app-orchestrator-3");
expect(mockPush).toHaveBeenCalledWith("/projects/my-project/sessions/app-orchestrator");
});
});
@ -116,11 +101,11 @@ describe("OrchestratorSelector", () => {
render(<OrchestratorSelector {...defaultProps} />);
const button = screen.getByRole("button", { name: /start new orchestrator/i });
const button = screen.getByRole("button", { name: /open orchestrator/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByText(/creating new orchestrator/i)).toBeInTheDocument();
expect(screen.getByText(/opening orchestrator/i)).toBeInTheDocument();
});
});
@ -133,7 +118,7 @@ describe("OrchestratorSelector", () => {
render(<OrchestratorSelector {...defaultProps} />);
const button = screen.getByRole("button", { name: /start new orchestrator/i });
const button = screen.getByRole("button", { name: /open orchestrator/i });
fireEvent.click(button);
await waitFor(() => {
@ -144,8 +129,8 @@ describe("OrchestratorSelector", () => {
it("links to orchestrator session page", () => {
render(<OrchestratorSelector {...defaultProps} />);
const link = screen.getByRole("link", { name: /app-orchestrator-1/i });
expect(link).toHaveAttribute("href", "/projects/my-project/sessions/app-orchestrator-1");
const link = screen.getByRole("link", { name: /app-orchestrator/i });
expect(link).toHaveAttribute("href", "/projects/my-project/sessions/app-orchestrator");
});
it("displays status and activity for each orchestrator", () => {
@ -153,7 +138,6 @@ describe("OrchestratorSelector", () => {
expect(screen.getByText("working")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText("spawning")).toBeInTheDocument();
});
it("covers relative time for days and status colors/labels", () => {
@ -196,12 +180,7 @@ describe("OrchestratorSelector", () => {
},
];
render(
<OrchestratorSelector
{...defaultProps}
orchestrators={wideOrchestrators}
/>,
);
render(<OrchestratorSelector {...defaultProps} orchestrators={wideOrchestrators} />);
expect(screen.getByText(/2d ago/)).toBeInTheDocument();
expect(screen.getAllByText(/Just now/i).length).toBeGreaterThan(0);
@ -222,10 +201,7 @@ describe("OrchestratorSelector", () => {
},
];
render(
<OrchestratorSelector
{...defaultProps}
orchestrators={orchestratorsWithInvalidDate}
/>,
<OrchestratorSelector {...defaultProps} orchestrators={orchestratorsWithInvalidDate} />,
);
// The "Created Unknown" text should appear for invalid dates
@ -242,10 +218,7 @@ describe("OrchestratorSelector", () => {
},
];
render(
<OrchestratorSelector
{...defaultProps}
orchestrators={orchestratorsWithFutureDate}
/>,
<OrchestratorSelector {...defaultProps} orchestrators={orchestratorsWithFutureDate} />,
);
// Future timestamps should show "Just now" instead of negative values
@ -260,12 +233,7 @@ describe("OrchestratorSelector", () => {
lastActivityAt: null,
},
];
render(
<OrchestratorSelector
{...defaultProps}
orchestrators={orchestratorsWithNullDate}
/>,
);
render(<OrchestratorSelector {...defaultProps} orchestrators={orchestratorsWithNullDate} />);
expect(screen.getByText(/Created Unknown/)).toBeInTheDocument();
});

View File

@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ProjectSidebar } from "@/components/ProjectSidebar";
import { makeSession } from "@/__tests__/helpers";
import { makePR, makeSession } from "@/__tests__/helpers";
const mockPush = vi.fn();
const mockRefresh = vi.fn();
@ -202,7 +202,10 @@ describe("ProjectSidebar", () => {
json: async () => ({ ok: true }),
});
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal("confirm", vi.fn(() => true));
vi.stubGlobal(
"confirm",
vi.fn(() => true),
);
render(
<ProjectSidebar
@ -254,6 +257,94 @@ describe("ProjectSidebar", () => {
expect(screen.queryByRole("link", { name: "Open feat/test" })).not.toBeInTheDocument();
});
it("keeps killed sessions visible when they still need attention", () => {
const lastActivityAt = new Date().toISOString();
render(
<ProjectSidebar
projects={projects}
sessions={[
makeSession({
id: "worker-ended",
projectId: "project-1",
summary: "Runtime missing but needs review",
branch: null,
status: "killed",
activity: "exited",
lastActivityAt,
pr: makePR({
title: "Runtime missing but needs review",
ciStatus: "failing",
mergeability: {
mergeable: false,
ciPassing: false,
approved: true,
noConflicts: true,
blockers: ["CI failing"],
},
}),
lifecycle: {
sessionState: "detecting",
sessionReason: "probe_failure",
prState: "open",
prReason: "ci_failing",
runtimeState: "missing",
runtimeReason: "process_missing",
session: {
state: "detecting",
reason: "probe_failure",
label: "Detecting",
reasonLabel: "Probe failure",
startedAt: lastActivityAt,
completedAt: null,
terminatedAt: null,
lastTransitionAt: lastActivityAt,
},
pr: {
state: "open",
reason: "ci_failing",
label: "Open",
reasonLabel: "CI failing",
number: 100,
url: "https://github.com/acme/app/pull/100",
lastObservedAt: lastActivityAt,
},
runtime: {
state: "missing",
reason: "process_missing",
label: "Missing",
reasonLabel: "Process missing",
lastObservedAt: lastActivityAt,
},
legacyStatus: "killed",
evidence: null,
detectingAttempts: 1,
detectingEscalatedAt: null,
summary: "Session detecting, PR open, runtime missing",
guidance: null,
},
}),
makeSession({
id: "worker-done",
projectId: "project-1",
summary: "Actually finished",
status: "merged",
activity: "exited",
pr: makePR({ state: "merged" }),
}),
]}
activeProjectId="project-1"
activeSessionId="worker-ended"
/>,
);
expect(screen.getByRole("button", { name: /^Project One 1$/ })).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Open Runtime missing but needs review" }),
).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Open Actually finished" })).not.toBeInTheDocument();
});
it("navigates session rows to the selected session detail route", () => {
mockPathname = "/sessions/ao-143";
@ -341,6 +432,6 @@ describe("ProjectSidebar", () => {
);
expect(screen.getByLabelText("Loading sessions")).toBeInTheDocument();
expect(screen.queryByText("No active sessions")).not.toBeInTheDocument();
expect(screen.queryByText("No sessions shown")).not.toBeInTheDocument();
});
});

View File

@ -120,10 +120,9 @@ describe("SessionDetail desktop layout", () => {
expect(screen.getByRole("button", { name: "Toggle sidebar" })).toBeInTheDocument();
expect(screen.getAllByText("My App").length).toBeGreaterThanOrEqual(1);
// Scope to topbar since MobileBottomNav also has an Orchestrator link
expect(within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" })).toHaveAttribute(
"href",
"/projects/my-app/sessions/my-app-orchestrator",
);
expect(
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
// Branch pill is rendered as link when session has a PR
expect(screen.getByRole("link", { name: "feat/desktop-detail" })).toHaveAttribute(
"href",
@ -191,7 +190,7 @@ describe("SessionDetail desktop layout", () => {
expect(screen.getByRole("button", { name: "Ask Agent to Fix" })).toBeInTheDocument();
});
it("shows terminal-ended placeholder for exited desktop sessions", () => {
it("shows an actionable summary for exited desktop sessions", () => {
render(
<SessionDetail
session={makeSession({
@ -199,12 +198,30 @@ describe("SessionDetail desktop layout", () => {
projectId: "my-app",
status: "terminated",
activity: "exited",
summary: "Investigated the dashboard loading issue",
branch: "fix/session-loading",
pr: null,
})}
/>,
);
expect(screen.getByText(/Terminal session has ended/i)).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Session ended summary" })).toBeInTheDocument();
expect(screen.getByText("Terminal ended")).toBeInTheDocument();
expect(screen.getByText("Investigated the dashboard loading issue")).toBeInTheDocument();
expect(
within(screen.getByLabelText("Session facts")).getByText("worker-ended"),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Restore terminal" })).not.toBeInTheDocument();
expect(
within(screen.getByRole("banner")).getByRole("button", { name: "Restore" }),
).toBeInTheDocument();
expect(
within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }),
).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute(
"href",
"/projects/my-app",
);
expect(screen.queryByTestId("direct-terminal")).not.toBeInTheDocument();
});
@ -233,11 +250,39 @@ describe("SessionDetail desktop layout", () => {
/>,
);
expect(within(screen.getByRole("banner")).getByRole("button", { name: "Restore" })).toBeInTheDocument();
expect(within(screen.getByRole("banner")).queryByRole("button", { name: "Kill" })).not.toBeInTheDocument();
expect(within(screen.getByRole("banner")).getByRole("button", { name: "Restore" })).toHaveClass(
"dashboard-app-btn--restore",
);
expect(
within(screen.getByRole("banner")).queryByRole("button", { name: "Kill" }),
).not.toBeInTheDocument();
});
it("hides the desktop orchestrator button on orchestrator session pages", () => {
it("restores without using router refresh on the client-only session page", async () => {
render(
<SessionDetail
session={makeSession({
id: "worker-restore",
projectId: "my-app",
status: "terminated",
activity: "exited",
pr: null,
})}
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Restore" }));
await act(async () => {});
expect(global.fetch).toHaveBeenCalledWith("/api/sessions/worker-restore/restore", {
method: "POST",
});
expect(routerRefreshMock).not.toHaveBeenCalled();
});
it("keeps the desktop orchestrator button on orchestrator session pages", () => {
render(
<SessionDetail
session={makeSession({
@ -258,12 +303,63 @@ describe("SessionDetail desktop layout", () => {
/>,
);
// Topbar should NOT show an Orchestrator link when already on orchestrator.
// Scope to banner since MobileBottomNav keeps its own tab link for active highlighting.
expect(within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" })).not.toBeInTheDocument();
expect(
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
expect(screen.getByText("orchestrator")).toBeInTheDocument();
});
it("shows the main orchestrator button when an orchestrator target exists", () => {
const { rerender } = render(
<SessionDetail
session={makeSession({ id: "worker-with-orchestrator", projectId: "my-app" })}
projectOrchestratorId="my-app-orchestrator"
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
/>,
);
expect(
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
rerender(
<SessionDetail
session={makeSession({ id: "worker-without-orchestrator", projectId: "my-app" })}
projectOrchestratorId={null}
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
/>,
);
expect(
within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }),
).not.toBeInTheDocument();
rerender(
<SessionDetail
session={makeSession({
id: "my-app-orchestrator",
projectId: "my-app",
summary: "Project orchestrator",
})}
isOrchestrator
orchestratorZones={{
merge: 1,
respond: 0,
review: 0,
pending: 0,
working: 2,
done: 3,
}}
projectOrchestratorId="my-app-orchestrator"
projects={[{ id: "my-app", name: "My App", path: "/tmp/my-app" }]}
/>,
);
expect(
within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }),
).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator");
});
it("routes to the project orchestrator after killing a worker session", async () => {
render(
<SessionDetail
@ -283,7 +379,11 @@ describe("SessionDetail desktop layout", () => {
it("routes to the project dashboard after killing a worker with no orchestrator", async () => {
render(
<SessionDetail
session={makeSession({ id: "worker-kill-dashboard", projectId: "my-app", status: "running" })}
session={makeSession({
id: "worker-kill-dashboard",
projectId: "my-app",
status: "running",
})}
projectOrchestratorId={null}
/>,
);

View File

@ -24,8 +24,20 @@ describe("useSessionEvents - mux", () => {
it("triggers refresh when mux patch contains unknown id", async () => {
const initialSessions = [s1];
const muxSessions = [
{ id: "s1", status: "working", activity: "active", attentionLevel: "working" as const, lastActivityAt: now },
{ id: "s2", status: "working", activity: "active", attentionLevel: "working" as const, lastActivityAt: now },
{
id: "s1",
status: "working",
activity: "active",
attentionLevel: "working" as const,
lastActivityAt: now,
},
{
id: "s2",
status: "working",
activity: "active",
attentionLevel: "working" as const,
lastActivityAt: now,
},
];
renderHook(() =>
useSessionEvents({
@ -42,4 +54,59 @@ describe("useSessionEvents - mux", () => {
);
});
});
it("does not warn when an in-flight refresh is aborted on unmount", async () => {
vi.useFakeTimers();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.stubGlobal(
"fetch",
vi.fn(
(_input: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((_, reject) => {
init?.signal?.addEventListener(
"abort",
() => reject(new DOMException("The operation was aborted.", "AbortError")),
{ once: true },
);
}),
),
);
const initialSessions = [s1];
const muxSessions = [
{
id: "s1",
status: "working",
activity: "active",
attentionLevel: "working" as const,
lastActivityAt: now,
},
{
id: "s2",
status: "working",
activity: "active",
attentionLevel: "working" as const,
lastActivityAt: now,
},
];
const { unmount } = renderHook(() =>
useSessionEvents({
initialSessions,
project: "proj",
muxSessions,
attentionZones: "simple",
}),
);
await vi.advanceTimersByTimeAsync(120);
unmount();
await Promise.resolve();
expect(warnSpy).not.toHaveBeenCalledWith(
"[useSessionEvents] refresh failed:",
expect.anything(),
);
vi.useRealTimers();
});
});

View File

@ -8,6 +8,7 @@ import {
type DashboardSession,
type SSESnapshotEvent,
} from "@/lib/types";
import { fetchJsonWithTimeout } from "@/lib/client-fetch";
/** Debounce before fetching full session list after membership change. */
const MEMBERSHIP_REFRESH_DELAY_MS = 120;
@ -15,16 +16,30 @@ const MEMBERSHIP_REFRESH_DELAY_MS = 120;
const STALE_REFRESH_INTERVAL_MS = 15000;
/** Grace period before declaring "disconnected" (allows for transient reconnects). */
const DISCONNECTED_GRACE_PERIOD_MS = 4000;
const LIVE_REFRESH_TIMEOUT_MS = 6000;
function isAbortLikeError(error: unknown): boolean {
if (error instanceof DOMException) {
return error.name === "AbortError";
}
if (error instanceof Error) {
const message = error.message.toLowerCase();
return message.includes("aborted") || message.includes("aborterror");
}
return false;
}
type ConnectionStatus = "connected" | "reconnecting" | "disconnected";
/** Server-computed attention levels from the latest SSE snapshot. */
export type SSEAttentionMap = Readonly<Record<string, AttentionLevel>>;
interface State {
sessions: DashboardSession[];
connectionStatus: ConnectionStatus;
loadError?: string;
/** Attention levels from the latest SSE snapshot (server-computed, includes PR state). */
sseAttentionLevels: SSEAttentionMap;
/**
@ -38,6 +53,7 @@ interface State {
type Action =
| { type: "reset"; sessions: DashboardSession[]; sseAttentionLevels?: SSEAttentionMap }
| { type: "snapshot"; patches: SSESnapshotEvent["sessions"] }
| { type: "setLoadError"; message?: string }
| { type: "setConnection"; status: ConnectionStatus }
| { type: "markLiveSessionsResolved" };
@ -47,10 +63,14 @@ function reducer(state: State, action: Action): State {
return {
...state,
sessions: action.sessions,
loadError: undefined,
...(action.sseAttentionLevels !== undefined
? { sseAttentionLevels: action.sseAttentionLevels }
: {}),
};
case "setLoadError":
if (state.loadError === action.message) return state;
return { ...state, loadError: action.message };
case "markLiveSessionsResolved":
if (state.liveSessionsResolved) return state;
return { ...state, liveSessionsResolved: true };
@ -70,7 +90,12 @@ function reducer(state: State, action: Action): State {
return s;
}
changed = true;
return { ...s, status: patch.status, activity: patch.activity, lastActivityAt: patch.lastActivityAt };
return {
...s,
status: patch.status,
activity: patch.activity,
lastActivityAt: patch.lastActivityAt,
};
});
// Build attention level map from server-computed values
@ -84,11 +109,18 @@ function reducer(state: State, action: Action): State {
Object.keys(levels).length !== Object.keys(state.sseAttentionLevels).length ||
action.patches.some((p) => state.sseAttentionLevels[p.id] !== p.attentionLevel);
if (!sessionsChanged && !levelsChanged) return state;
if (!sessionsChanged && !levelsChanged) {
if (state.loadError === undefined) return state;
return {
...state,
loadError: undefined,
};
}
return {
...state,
sessions: sessionsChanged ? next : state.sessions,
loadError: undefined,
sseAttentionLevels: levelsChanged ? levels : state.sseAttentionLevels,
};
}
@ -107,7 +139,13 @@ function createMembershipKey(
export interface UseSessionEventsOptions {
initialSessions: DashboardSession[];
project?: string;
muxSessions?: Array<{ id: string; status: string; activity: string | null; attentionLevel: AttentionLevel; lastActivityAt: string }>;
muxSessions?: Array<{
id: string;
status: string;
activity: string | null;
attentionLevel: AttentionLevel;
lastActivityAt: string;
}>;
initialAttentionLevels?: SSEAttentionMap;
disabled?: boolean;
/**
@ -136,6 +174,7 @@ export function useSessionEvents(options: UseSessionEventsOptions): State {
const [state, dispatch] = useReducer(reducer, {
sessions: initialSessions,
connectionStatus: "connected" as ConnectionStatus,
loadError: undefined,
sseAttentionLevels: initialAttentionLevels ?? ({} as SSEAttentionMap),
liveSessionsResolved: false,
});
@ -148,6 +187,7 @@ export function useSessionEvents(options: UseSessionEventsOptions): State {
const lastFetchStartedAtRef = useRef(0);
const disconnectedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeRefreshControllerRef = useRef<AbortController | null>(null);
const pageUnloadingRef = useRef(false);
// Reset state when server-rendered props change (e.g. full page refresh)
useEffect(() => {
@ -167,6 +207,22 @@ export function useSessionEvents(options: UseSessionEventsOptions): State {
// SSE setup/teardown runs only on that transition, not every mux update.
const muxActive = muxSessions !== undefined;
useEffect(() => {
pageUnloadingRef.current = false;
const markPageUnloading = () => {
pageUnloadingRef.current = true;
};
window.addEventListener("pagehide", markPageUnloading);
window.addEventListener("beforeunload", markPageUnloading);
return () => {
window.removeEventListener("pagehide", markPageUnloading);
window.removeEventListener("beforeunload", markPageUnloading);
};
}, []);
// Define scheduleRefresh with useCallback so both effects can use it
const scheduleRefresh = useCallback(() => {
// Skip scheduling if a timer is already pending
@ -190,33 +246,40 @@ export function useSessionEvents(options: UseSessionEventsOptions): State {
? `/api/sessions?project=${encodeURIComponent(project)}`
: "/api/sessions";
void fetch(sessionsUrl, { signal: refreshController.signal, cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then(
(updated: { sessions?: DashboardSession[] } | null) => {
if (refreshController.signal.aborted || !updated?.sessions) {
// Update timestamp even for non-OK responses to prevent retry storms
if (!refreshController.signal.aborted) {
lastRefreshAtRef.current = Date.now();
}
return;
void fetchJsonWithTimeout<{ sessions?: DashboardSession[] } | null>(sessionsUrl, {
signal: refreshController.signal,
cache: "no-store",
timeoutMs: LIVE_REFRESH_TIMEOUT_MS,
timeoutMessage: `Dashboard refresh timed out after ${LIVE_REFRESH_TIMEOUT_MS}ms`,
})
.then((updated) => {
if (refreshController.signal.aborted || !updated?.sessions) {
// Update timestamp even for non-OK responses to prevent retry storms
if (!refreshController.signal.aborted) {
lastRefreshAtRef.current = Date.now();
}
return;
}
lastRefreshAtRef.current = Date.now();
dispatch({ type: "markLiveSessionsResolved" });
const sseAttentionLevels = Object.fromEntries(
updated.sessions.map((s) => [s.id, getAttentionLevel(s, attentionZones)]),
) as SSEAttentionMap;
dispatch({
type: "reset",
sessions: updated.sessions,
sseAttentionLevels,
});
},
)
lastRefreshAtRef.current = Date.now();
dispatch({ type: "markLiveSessionsResolved" });
const sseAttentionLevels = Object.fromEntries(
updated.sessions.map((s) => [s.id, getAttentionLevel(s, attentionZones)]),
) as SSEAttentionMap;
dispatch({
type: "reset",
sessions: updated.sessions,
sseAttentionLevels,
});
})
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") return;
if (pageUnloadingRef.current || refreshController.signal.aborted || isAbortLikeError(err))
return;
console.warn("[useSessionEvents] refresh failed:", err);
dispatch({
type: "setLoadError",
message: err instanceof Error ? err.message : "Dashboard refresh failed",
});
// Update timestamp on failure to prevent retry loops on every SSE snapshot
lastRefreshAtRef.current = Date.now();
})
@ -325,7 +388,14 @@ export function useSessionEvents(options: UseSessionEventsOptions): State {
es.onmessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data as string) as { type: string };
const data = JSON.parse(event.data as string) as { type: string; error?: string };
if (data.type === "error") {
dispatch({
type: "setLoadError",
message: data.error ?? "Live dashboard updates failed",
});
return;
}
if (data.type === "snapshot") {
const snapshot = data as SSESnapshotEvent;
dispatch({ type: "markLiveSessionsResolved" });

View File

@ -73,7 +73,7 @@ describe("getDashboardPageData fast path", () => {
hoisted.getServicesMock.mockResolvedValue({
config: { projects: { docs: { id: "docs" } } },
registry: { scm: "registry" },
sessionManager: { list: vi.fn().mockResolvedValue(allSessions) },
sessionManager: { listCached: vi.fn().mockResolvedValue(allSessions) },
});
hoisted.filterProjectSessionsMock.mockReturnValue(allSessions);
hoisted.filterWorkerSessionsMock.mockReturnValue(allSessions);
@ -116,7 +116,7 @@ describe("getDashboardPageData fast path", () => {
hoisted.getServicesMock.mockResolvedValue({
config: { projects: { mono: { id: "mono" } } },
registry: { scm: "registry" },
sessionManager: { list: vi.fn().mockResolvedValue([core]) },
sessionManager: { listCached: vi.fn().mockResolvedValue([core]) },
});
hoisted.filterProjectSessionsMock.mockReturnValue([core]);
hoisted.filterWorkerSessionsMock.mockReturnValue([core]);
@ -147,7 +147,7 @@ describe("getDashboardPageData fast path", () => {
hoisted.getServicesMock.mockResolvedValue({
config: { projects: { mono: { id: "mono" } } },
registry: { scm: "registry" },
sessionManager: { list: vi.fn().mockResolvedValue([core]) },
sessionManager: { listCached: vi.fn().mockResolvedValue([core]) },
});
hoisted.filterProjectSessionsMock.mockReturnValue([core]);
hoisted.filterWorkerSessionsMock.mockReturnValue([core]);
@ -181,7 +181,7 @@ describe("getDashboardPageData fast path", () => {
dashboard: { attentionZones: "detailed" },
},
registry: { scm: "registry" },
sessionManager: { list: vi.fn().mockRejectedValue(new Error("list boom")) },
sessionManager: { listCached: vi.fn().mockRejectedValue(new Error("list boom")) },
});
const pageData = await getDashboardPageData("docs");
@ -198,7 +198,7 @@ describe("getDashboardPageData fast path", () => {
hoisted.getServicesMock.mockResolvedValue({
config: { projects: { docs: { id: "docs" } } },
registry: { scm: "registry" },
sessionManager: { list: vi.fn().mockResolvedValue([core]) },
sessionManager: { listCached: vi.fn().mockResolvedValue([core]) },
});
hoisted.filterProjectSessionsMock.mockReturnValue([core]);
hoisted.filterWorkerSessionsMock.mockReturnValue([core]);

View File

@ -1,4 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const { mockLoadConfig, mockGetGlobalConfigPath, MockConfigNotFoundError } = vi.hoisted(() => {
const mockLoadConfig = vi.fn();
@ -21,10 +24,12 @@ vi.mock("@aoagents/ao-core", () => ({
describe("project-name fallback discovery", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.resetModules();
mockLoadConfig.mockReset();
mockGetGlobalConfigPath.mockClear();
mockGetGlobalConfigPath.mockReturnValue("/tmp/global-config.yaml");
delete process.env["AO_CONFIG_PATH"];
});
it("falls back to discovered local config when the canonical global config is missing", async () => {
@ -52,4 +57,160 @@ describe("project-name fallback discovery", () => {
expect(mockLoadConfig).toHaveBeenNthCalledWith(1, "/tmp/global-config.yaml");
expect(mockLoadConfig).toHaveBeenNthCalledWith(2);
});
it("prefers the current repo project over the first configured project", async () => {
const config = {
configPath: "/tmp/global-config.yaml",
projects: {
"vinesight-web": {
name: "vinesight-web",
path: "/Users/ashishhuddar/vinesight",
sessionPrefix: "vw",
},
"agent-orchestrator": {
name: "Agent Orchestrator",
path: "/Users/ashishhuddar/agent-orchestrator",
sessionPrefix: "ao",
},
},
degradedProjects: {},
};
mockLoadConfig.mockReturnValue(config);
vi.spyOn(process, "cwd").mockReturnValue("/Users/ashishhuddar/agent-orchestrator");
const { getPrimaryProjectId, getProjectName } = await import("../project-name");
expect(getPrimaryProjectId()).toBe("agent-orchestrator");
expect(getProjectName()).toBe("Agent Orchestrator");
});
it("does not infer the current project from an ambiguous path basename", async () => {
const config = {
configPath: "/tmp/global-config.yaml",
projects: {
first: {
name: "First",
path: "/repos/client-a/integrator",
sessionPrefix: "first",
},
second: {
name: "Second",
path: "/repos/client-b/integrator",
sessionPrefix: "second",
},
},
degradedProjects: {},
};
mockLoadConfig.mockReturnValue(config);
vi.spyOn(process, "cwd").mockReturnValue("/tmp/checkout/integrator");
const { getPrimaryProjectId, getProjectName } = await import("../project-name");
expect(getPrimaryProjectId()).toBe("first");
expect(getProjectName()).toBe("First");
});
it("prefers the repo discovered from local config when the dashboard is running from packages/web", async () => {
const tempRoot = mkdtempSync(join(tmpdir(), "ao-project-name-web-"));
const repoRoot = join(tempRoot, "agent-orchestrator");
const webDir = join(repoRoot, "packages", "web");
mkdirSync(webDir, { recursive: true });
const localConfigPath = join(repoRoot, "agent-orchestrator.yaml");
writeFileSync(localConfigPath, "projects: {}\n");
const globalConfig = {
configPath: "/tmp/global-config.yaml",
projects: {
"vinesight-web": {
name: "vinesight-web",
path: join(tempRoot, "vinesight"),
sessionPrefix: "vw",
},
"agent-orchestrator": {
name: "Agent Orchestrator",
path: repoRoot,
sessionPrefix: "ao",
},
},
degradedProjects: {},
};
const localConfig = {
configPath: localConfigPath,
projects: {
"agent-orchestrator": {
name: "Agent Orchestrator",
path: repoRoot,
sessionPrefix: "ao",
},
},
degradedProjects: {},
};
mockLoadConfig.mockImplementation((configPath?: string) => {
if (configPath === "/tmp/global-config.yaml") {
return globalConfig;
}
if (configPath === localConfigPath) {
return localConfig;
}
throw new Error(`unexpected config path: ${String(configPath)}`);
});
vi.spyOn(process, "cwd").mockReturnValue(webDir);
const { getPrimaryProjectId, getProjectName } = await import("../project-name");
expect(getPrimaryProjectId()).toBe("agent-orchestrator");
expect(getProjectName()).toBe("Agent Orchestrator");
});
it("ignores ambient AO_CONFIG_PATH when discovering the local repo project", async () => {
const tempRoot = mkdtempSync(join(tmpdir(), "ao-project-name-"));
const repoRoot = join(tempRoot, "agent-orchestrator");
const webDir = join(repoRoot, "packages", "web");
mkdirSync(webDir, { recursive: true });
const localConfigPath = join(repoRoot, "agent-orchestrator.yaml");
writeFileSync(localConfigPath, "projects: {}\n");
const globalConfig = {
configPath: "/tmp/global-config.yaml",
projects: {
healthy: {
name: "Healthy",
path: repoRoot,
sessionPrefix: "healthy",
},
},
degradedProjects: {},
};
const localConfig = {
configPath: localConfigPath,
projects: {
healthy: {
name: "Healthy",
path: repoRoot,
sessionPrefix: "healthy",
},
},
degradedProjects: {},
};
process.env["AO_CONFIG_PATH"] = "/tmp/ambient-config.yaml";
mockLoadConfig.mockImplementation((configPath?: string) => {
if (configPath === "/tmp/global-config.yaml") {
return globalConfig;
}
if (configPath === localConfigPath) {
return localConfig;
}
throw new Error(`unexpected config path: ${String(configPath)}`);
});
vi.spyOn(process, "cwd").mockReturnValue(webDir);
const { getPrimaryProjectId, getProjectName } = await import("../project-name");
expect(getPrimaryProjectId()).toBe("healthy");
expect(getProjectName()).toBe("Healthy");
});
});

View File

@ -167,7 +167,7 @@ describe("sessionToDashboard", () => {
expect(dashboard.attentionLevel).toBe("working");
});
it("should expose detecting guidance and evidence from legacy metadata", () => {
it("should expose detecting evidence from legacy metadata without card guidance copy", () => {
const lifecycle = createInitialCanonicalLifecycle("worker", new Date("2025-01-01T00:00:00Z"));
lifecycle.session.state = "detecting";
lifecycle.session.reason = "probe_failure";
@ -184,7 +184,7 @@ describe("sessionToDashboard", () => {
const dashboard = sessionToDashboard(coreSession);
expect(dashboard.lifecycle?.guidance).toContain("Retry 2");
expect(dashboard.lifecycle?.guidance).toBeNull();
expect(dashboard.lifecycle?.evidence).toContain("signal_disagreement");
expect(dashboard.attentionLevel).toBe("respond");
});
@ -1028,15 +1028,18 @@ describe("enrichSessionsMetadata", () => {
});
it("starts issue-title fetches before agent summaries finish", async () => {
let resolveSummary: ((value: { summary: string; summaryIsFallback: false; agentSessionId: string }) => void) | null = null;
let resolveSummary:
| ((value: { summary: string; summaryIsFallback: false; agentSessionId: string }) => void)
| null = null;
const tracker = mockTracker("Fix auth bug");
const agent = {
...mockAgent(),
getSessionInfo: vi.fn().mockImplementation(
() => new Promise((resolve) => {
resolveSummary = resolve;
}),
() =>
new Promise((resolve) => {
resolveSummary = resolve;
}),
),
} as Agent;
const registry = mockRegistry(tracker, agent);
@ -1207,7 +1210,9 @@ describe("enrichSessionsMetadataFast", () => {
const urlBase = "https://github.com/test/repo/issues/fast";
const tracker: Tracker = {
name: "mock-tracker",
getIssue: vi.fn().mockResolvedValue({ id: "99", title: "Should not be called", url: urlBase }),
getIssue: vi
.fn()
.mockResolvedValue({ id: "99", title: "Should not be called", url: urlBase }),
issueLabel: vi.fn().mockReturnValue("#99"),
} as unknown as Tracker;
const agent: Agent = {

View File

@ -0,0 +1,94 @@
"use client";
interface FetchJsonOptions extends RequestInit {
timeoutMs?: number;
timeoutMessage?: string;
}
function mergeAbortSignals(
signals: Array<AbortSignal | null | undefined>,
): AbortSignal | undefined {
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
if (activeSignals.length === 0) return undefined;
if (activeSignals.length === 1) return activeSignals[0];
const controller = new AbortController();
const abort = () => controller.abort();
for (const signal of activeSignals) {
if (signal.aborted) {
controller.abort();
break;
}
signal.addEventListener("abort", abort, { once: true });
}
return controller.signal;
}
async function readErrorMessage(response: Response): Promise<string> {
try {
const payload = (await response.json()) as { error?: string; message?: string } | null;
const message = payload?.error ?? payload?.message;
if (typeof message === "string" && message.trim().length > 0) {
return message.trim();
}
} catch {
// Ignore parse failures and fall back to status text.
}
const statusText = typeof response.statusText === "string" ? response.statusText.trim() : "";
if (statusText.length > 0) {
return `${response.status} ${statusText}`;
}
return `HTTP ${response.status}`;
}
export async function fetchJsonWithTimeout<T>(
input: RequestInfo | URL,
options: FetchJsonOptions = {},
): Promise<T> {
const { timeoutMs = 8_000, timeoutMessage, signal, ...init } = options;
const timeoutController = new AbortController();
let timer: ReturnType<typeof setTimeout> | null = null;
let timedOut = false;
try {
const mergedSignal = mergeAbortSignals([signal, timeoutController.signal]);
const requestInit: RequestInit = { ...init };
if (mergedSignal) {
requestInit.signal = mergedSignal;
}
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
timedOut = true;
timeoutController.abort();
reject(new Error(timeoutMessage ?? `Request timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
const response = await Promise.race([
fetch(input, requestInit).catch((error: unknown) => {
if (timedOut) {
throw new Error(timeoutMessage ?? `Request timed out after ${timeoutMs}ms`, {
cause: error,
});
}
throw error;
}),
timeoutPromise,
]);
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
return (await response.json()) as T;
} finally {
if (timer) {
clearTimeout(timer);
}
}
}

View File

@ -90,7 +90,7 @@ export const getDashboardPageData = cache(async function getDashboardPageData(pr
let config: Awaited<ReturnType<typeof getServices>>["config"];
let registry: Awaited<ReturnType<typeof getServices>>["registry"];
let allSessions: Awaited<ReturnType<Awaited<ReturnType<typeof getServices>>["sessionManager"]["list"]>>;
let allSessions: Awaited<ReturnType<Awaited<ReturnType<typeof getServices>>["sessionManager"]["listCached"]>>;
try {
const services = await getServices();
@ -98,7 +98,7 @@ export const getDashboardPageData = cache(async function getDashboardPageData(pr
registry = services.registry;
pageData.attentionZones = config.dashboard?.attentionZones ?? DEFAULT_ATTENTION_ZONE_MODE;
try {
allSessions = await services.sessionManager.list();
allSessions = await services.sessionManager.listCached();
} catch (listErr) {
pageData.dashboardLoadError = formatDashboardLoadError(listErr);
return pageData;

View File

@ -1,6 +1,8 @@
import "server-only";
import { cache } from "react";
import { existsSync, realpathSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { ConfigNotFoundError, getGlobalConfigPath, loadConfig } from "@aoagents/ao-core";
export interface ProjectInfo {
@ -26,9 +28,97 @@ function loadProjectDiscoveryConfig() {
}
}
function getCanonicalPath(path: string): string {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
}
function findProjectIdForPath(projectPath: string, config: ReturnType<typeof loadProjectDiscoveryConfig>): string | undefined {
const canonicalProjectPath = getCanonicalPath(projectPath);
for (const [projectId, project] of Object.entries(config.projects)) {
if (typeof project.path !== "string") continue;
if (getCanonicalPath(project.path) === canonicalProjectPath) {
return projectId;
}
}
return undefined;
}
function findLocalConfigPath(startDir: string): string | undefined {
let currentDir = resolve(startDir);
while (true) {
for (const filename of ["agent-orchestrator.yaml", "agent-orchestrator.yml"]) {
const candidate = resolve(currentDir, filename);
if (existsSync(candidate)) {
return candidate;
}
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
return undefined;
}
currentDir = parentDir;
}
}
function findDiscoveredRepoProjectId(config: ReturnType<typeof loadProjectDiscoveryConfig>): string | undefined {
try {
const localConfigPath = findLocalConfigPath(process.cwd());
if (!localConfigPath) {
return undefined;
}
const discoveredConfig = loadConfig(localConfigPath);
const canonicalGlobalConfigPath = getCanonicalPath(getGlobalConfigPath());
const canonicalDiscoveredConfigPath = getCanonicalPath(discoveredConfig.configPath);
if (canonicalDiscoveredConfigPath !== canonicalGlobalConfigPath) {
for (const project of Object.values(discoveredConfig.projects)) {
if (typeof project.path !== "string") continue;
const projectId = findProjectIdForPath(project.path, config);
if (projectId) return projectId;
}
return findProjectIdForPath(dirname(discoveredConfig.configPath), config);
}
} catch {
// Fall through to cwd-based discovery for environments without a local config.
}
return undefined;
}
function findCurrentRepoProjectId(
config: ReturnType<typeof loadProjectDiscoveryConfig> = loadProjectDiscoveryConfig(),
): string | undefined {
try {
const discoveredProjectId = findDiscoveredRepoProjectId(config);
if (discoveredProjectId) {
return discoveredProjectId;
}
const cwd = getCanonicalPath(process.cwd());
return findProjectIdForPath(cwd, config);
} catch {
return undefined;
}
}
export const getProjectName = cache((): string => {
try {
const config = loadProjectDiscoveryConfig();
const currentProjectId = findCurrentRepoProjectId(config);
if (currentProjectId) {
const currentProject = config.projects[currentProjectId];
return currentProject?.name ?? currentProjectId;
}
const firstKey = Object.keys(config.projects)[0];
if (firstKey) {
const name = config.projects[firstKey].name ?? firstKey;
@ -43,6 +133,8 @@ export const getProjectName = cache((): string => {
export const getPrimaryProjectId = cache((): string => {
try {
const config = loadProjectDiscoveryConfig();
const currentProjectId = findCurrentRepoProjectId(config);
if (currentProjectId) return currentProjectId;
const firstKey = Object.keys(config.projects)[0];
if (firstKey) return firstKey;
} catch {

View File

@ -105,22 +105,6 @@ function buildLifecycleSummary(session: Session): string {
return `Session ${humanizeLifecycleToken(lifecycle.session.state)} (${humanizeLifecycleToken(lifecycle.session.reason)})`;
}
function buildLifecycleGuidance(session: Session): string | null {
const { lifecycle, metadata } = session;
if (lifecycle.session.state !== "detecting") {
return null;
}
const attempts = Number.parseInt(metadata["detectingAttempts"] ?? "0", 10);
const normalizedAttempts = Number.isFinite(attempts) ? attempts : 0;
if (metadata["detectingEscalatedAt"]) {
return "Detection retries exhausted. Inspect runtime evidence or restore the session manually.";
}
if (normalizedAttempts > 0) {
return `Checking runtime and process evidence now. Retry ${normalizedAttempts} is in progress.`;
}
return "Checking runtime and process evidence now.";
}
function buildDashboardLifecycle(session: Session): NonNullable<DashboardSession["lifecycle"]> {
const lifecycle = session.lifecycle;
return {
@ -161,7 +145,7 @@ function buildDashboardLifecycle(session: Session): NonNullable<DashboardSession
detectingAttempts: Number.parseInt(session.metadata["detectingAttempts"] ?? "0", 10) || 0,
detectingEscalatedAt: session.metadata["detectingEscalatedAt"] ?? null,
summary: buildLifecycleSummary(session),
guidance: buildLifecycleGuidance(session),
guidance: null,
};
}
@ -246,7 +230,8 @@ export function listDashboardOrchestrators(
}
const currentActivity = current.lastActivityAt?.getTime() ?? current.createdAt?.getTime() ?? 0;
const candidateActivity = session.lastActivityAt?.getTime() ?? session.createdAt?.getTime() ?? 0;
const candidateActivity =
session.lastActivityAt?.getTime() ?? session.createdAt?.getTime() ?? 0;
if (candidateActivity > currentActivity) {
bestByProject.set(session.projectId, session);
continue;
@ -300,7 +285,9 @@ function basicPRToDashboard(pr: PRInfo): DashboardPR {
};
}
function normalizeDashboardPRState(state: Session["lifecycle"]["pr"]["state"]): DashboardPR["state"] {
function normalizeDashboardPRState(
state: Session["lifecycle"]["pr"]["state"],
): DashboardPR["state"] {
switch (state) {
case "merged":
return "merged";
@ -616,7 +603,10 @@ function prepareSessionMetadataEnrichment(
// Issue labels (synchronous string parsing, no API calls)
projects.forEach((project, i) => {
if ((!dashboardSessions[i].issueUrl && !dashboardSessions[i].issueId) || !project?.tracker?.plugin) {
if (
(!dashboardSessions[i].issueUrl && !dashboardSessions[i].issueId) ||
!project?.tracker?.plugin
) {
return;
}
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);