diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 07dc6fce6..ea10e4b6e 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -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; diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 4832c23b2..d48bad073 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -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(() => {})); + const prSpy = vi + .spyOn(serialize, "enrichSessionPR") + .mockImplementation(() => new Promise(() => {})); + + 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, fn: () => Promise) { const saved: Record = {}; diff --git a/packages/web/src/__tests__/components.test.tsx b/packages/web/src/__tests__/components.test.tsx index acab690be..6fe8c1df6 100644 --- a/packages/web/src/__tests__/components.test.tsx +++ b/packages/web/src/__tests__/components.test.tsx @@ -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(); + expect( + screen.queryByText("Checking runtime and process evidence now."), + ).not.toBeInTheDocument(); + }); + it("renders terminal link", () => { const session = makeSession({ id: "backend-5" }); render(); 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(); // 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(); + + 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(); }); }); diff --git a/packages/web/src/app/api/events/route.ts b/packages/web/src/app/api/events/route.ts index 270fda151..d89014601 100644 --- a/packages/web/src/app/api/events/route.ts +++ b/packages/web/src/app/api/events/route.ts @@ -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 { let updates: ReturnType | 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 { 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 { 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 { 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 { 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 { 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(); diff --git a/packages/web/src/app/api/sessions/[id]/route.ts b/packages/web/src/app/api/sessions/[id]/route.ts index 5737f7f07..bcf208501 100644 --- a/packages/web/src/app/api/sessions/[id]/route.ts +++ b/packages/web/src/app/api/sessions/[id]/route.ts @@ -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, + ); } } } diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 9b8975bfe..bd833ab92 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -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 { diff --git a/packages/web/src/app/layout.test.ts b/packages/web/src/app/layout.test.ts index 5c904aa36..8e7b95bda 100644 --- a/packages/web/src/app/layout.test.ts +++ b/packages/web/src/app/layout.test.ts @@ -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"); diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 92fd36395..98e0547ce 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -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 { export default function RootLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/packages/web/src/app/orchestrators/page.tsx b/packages/web/src/app/orchestrators/page.tsx index 682a54bcd..63444c915 100644 --- a/packages/web/src/app/orchestrators/page.tsx +++ b/packages/web/src/app/orchestrators/page.tsx @@ -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"; diff --git a/packages/web/src/app/sessions/[id]/error.tsx b/packages/web/src/app/sessions/[id]/error.tsx index f9a0011c1..17e40a671 100644 --- a/packages/web/src/app/sessions/[id]/error.tsx +++ b/packages/web/src/app/sessions/[id]/error.tsx @@ -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."; } diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx index 51eeb2f3c..246c71425 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -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 = { 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 { }); } -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
{this.state.error.message}
; - } - 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(); 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(); 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( - - - , - ); - + render(); 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((_, 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(); + + 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(); + 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(); 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((_, 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(); + 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 () => { diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index 1a4ce8c1f..307cd7638 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -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(Object.values(SESSION_STATUS)); const validActivityStates = new Set(Object.values(ACTIVITY_STATE)); const warnedMuxPatchValues = new Set(); @@ -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 ( +
+
+
+ Projects +
+
+ {Array.from({ length: 3 }, (_, index) => ( +
+ ))} +
+
{message}
+
+
+ ); +} + +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 ( +
+
+ +
+ Agent Orchestrator +
+
+
+ +
+
+ {projects.length > 0 ? ( + setSidebarCollapsed((current) => !current)} + onMobileClose={() => setMobileSidebarOpen(false)} + /> + ) : ( + + )} +
+ {mobileSidebarOpen && ( +
setMobileSidebarOpen(false)} /> + )} + +
+
+
{children}
+
+
+
+
+ ); +} + +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(cachedSession); const [zoneCounts, setZoneCounts] = useState(null); - const [projectOrchestratorId, setProjectOrchestratorId] = useState(undefined); + const [projectOrchestratorId, setProjectOrchestratorId] = useState( + undefined, + ); const [projects, setProjects] = useState([]); - const [sidebarSessions, setSidebarSessions] = useState(() => cachedSidebarSessions); + const [projectsLoading, setProjectsLoading] = useState(cachedProjects === null); + const [sidebarSessions, setSidebarSessions] = useState( + () => cachedSidebarSessions, + ); const [loading, setLoading] = useState(cachedSession === null); const [routeError, setRouteError] = useState(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(null); + const projectSessionsFetchControllerRef = useRef(null); + const sidebarFetchControllerRef = useRef(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( + `/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(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 ( -
-
Loading session…
-
+ +
+
+ + + +
Loading session…
+
+
+
); } if (sessionMissing) { - notFound(); - return null; + return ( + + void fetchSession() }} + compact + chrome="card" + /> + + ); } if (routeError) { - throw routeError; + return ( + + { + 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" + /> + + ); } if (!session) { - throw new Error("Session data was unavailable after loading completed"); + return ( + + void fetchSession() }} + secondaryAction={{ + label: "Back to dashboard", + href: expectedProjectId ? `/projects/${expectedProjectId}` : "/", + }} + compact + chrome="card" + /> + + ); } return ( diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index cd36c63ee..9612fc6bf 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -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()} > - + @@ -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 = {}; 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 = { 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" > - Orchestrator failed to load - {visibleDashboardLoadError} + + Orchestrator failed to load + + + {visibleDashboardLoadError} + - Confirm agent-orchestrator.yaml exists and is valid, then run{" "} - ao doctor for diagnostics. + Confirm agent-orchestrator.yaml exists and is + valid, then run ao doctor for diagnostics.
) : null; @@ -460,7 +477,9 @@ function DashboardInner({ }; return ( - + <>
@@ -473,7 +492,15 @@ function DashboardInner({ aria-label="Toggle sidebar" > {isMobile ? ( -
+
-
+
{kanbanLevels.map((level) => ( - {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" : ""}` + : ""} )}
@@ -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"}
{isDegraded ? ( - {spawningProjectIds.includes(project.id) ? "Spawning..." : "Spawn Orchestrator"} + {spawningProjectIds.includes(project.id) + ? "Spawning..." + : "Spawn Orchestrator"} )}
@@ -830,8 +871,8 @@ function ProjectOverviewGrid({
); - })() - ))} + })(), + )}
); } diff --git a/packages/web/src/components/OrchestratorSelector.tsx b/packages/web/src/components/OrchestratorSelector.tsx index 26c0bd64a..a178a7a6a 100644 --- a/packages/web/src/components/OrchestratorSelector.tsx +++ b/packages/web/src/components/OrchestratorSelector.tsx @@ -154,7 +154,7 @@ export function OrchestratorSelector({

{projectName}

-

Select an orchestrator

+

Project orchestrator

- Found{" "} - - {orchestrators.length} - {" "} - 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.

{/* Existing orchestrators */}

- Existing Sessions + Main Session

{orchestrators.map((orch) => ( @@ -227,7 +223,7 @@ export function OrchestratorSelector({ {/* Start new section */}

- Or Start Fresh + Open Orchestrator

{spawnError && ( diff --git a/packages/web/src/components/ProjectSidebar.tsx b/packages/web/src/components/ProjectSidebar.tsx index 79678543f..e39aefdfd 100644 --- a/packages/web/src/components/ProjectSidebar.tsx +++ b/packages/web/src/components/ProjectSidebar.tsx @@ -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 ( -
); @@ -341,393 +342,447 @@ function ProjectSidebarInner({ } return ( -
{/* Expandable detail panel */} @@ -473,9 +483,7 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio cardDotTone === "exited" && "card__adot--exited", )} /> - - {session.id} - + {session.id}
{isRestorable && ( )} {!isTerminal && ( e.stopPropagation()} className="session-card__control session-card__terminal-link" > @@ -522,17 +536,11 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
-

- {title} -

+

{title}

- {session.branch && ( - - {session.branch} - - )} + {session.branch && {session.branch}} {session.branch && pr ? (
)} - {lifecycleGuidance && ( -
-

- {lifecycleGuidance} -

-
- )} - {rateLimited && pr?.state === "open" && (
@@ -621,7 +627,15 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio {visiblePassingChecks.map((check) => { const chipContent = ( <> -
{alerts.slice(0, 3).map((alert) => ( -
+
{alert.icon} {alert.notified && ( - {" "}· notified + {" "} + · notified )} @@ -700,7 +712,16 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
e.stopPropagation()}> {session.summary && !session.summaryIsFallback && (
- {session.summary} @@ -713,55 +734,61 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio > View current context → -
- - - -
-
-