From 844194006adb57e4c334be52a30ecd7f492be688 Mon Sep 17 00:00:00 2001 From: sam2tom Date: Sat, 19 Sep 2026 12:06:00 +0800 Subject: [PATCH] feat(web): improve dashboard and session recovery --- apps/web/e2e/agents-lifecycle.spec.ts | 100 ++- apps/web/e2e/fixture-core.mjs | 3 +- apps/web/src/App.tsx | 5 + apps/web/src/components/ConnectionModal.tsx | 4 +- .../features/CoreCollectionStates.test.tsx | 48 +- .../src/features/dashboard/DashboardView.css | 684 ++++++++++++------ .../features/dashboard/DashboardView.test.tsx | 69 +- .../src/features/dashboard/DashboardView.tsx | 407 +++++++---- .../dashboard/dashboard-model.test.ts | 4 +- .../src/features/dashboard/dashboard-model.ts | 6 +- .../src/features/sessions/SessionsView.tsx | 60 +- docs/protocol-coverage.md | 2 +- 12 files changed, 945 insertions(+), 447 deletions(-) diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index 2cab6c2..f7e32ac 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -54,7 +54,7 @@ async function emitTurnFixture(request: APIRequestContext, status: "completed" | expect(response.ok()).toBe(true); } -async function emitSessionFixture(request: APIRequestContext, status: "in_progress" | "idle") { +async function emitSessionFixture(request: APIRequestContext, status: "in_progress" | "idle" | "failed") { const response = await request.post(`${fixtureBaseUrl}/__fixture/emit-session`, { data: { status } }); expect(response.ok()).toBe(true); } @@ -1068,8 +1068,9 @@ test("keeps managed Environment resource and terminal event states fail-closed", environmentEventCount: 0, }); await page.reload(); - await expect(page.getByText("Session failed", { exact: true })).toBeVisible(); + await expect(page.getByText("Session cannot continue", { exact: true })).toBeVisible(); await expect(page.getByText("The environment is no longer available for this input.", { exact: true })).toBeVisible(); + await expect(page.getByLabel("Message the Agent")).toBeDisabled(); opened = await openEnvironmentDialog(page); await expect(opened.panel).toContainText("Managed Environment failed"); await expect(opened.panel.getByText("Add inline Workspace file", { exact: true })).toHaveCount(0); @@ -1432,7 +1433,7 @@ test("filters every Session page by Agent and aborts a stale filter read", async await page.getByRole("button", { name: "Dashboard", exact: true }).click(); const dashboard = page.locator(".dashboard-page"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Loaded Sessions" })).toContainText("1"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("1"); await page.getByRole("button", { name: "Sessions", exact: true }).click(); await expect(filter).toHaveValue("agent_b"); await expect(page.locator(".conversation-header h2")).toHaveText("Second Agent"); @@ -1955,6 +1956,41 @@ test("deletes an inactive Session without disturbing the active composer or live ))).toHaveLength(activeStreamReads); }); +test("continues a Session with a new Turn after its latest attempt fails", async ({ page, request }) => { + await resetFixture(request); + await page.goto("/"); + await expect(connectedLiveEvents(page)).toBeVisible(); + + await emitSessionFixture(request, "failed"); + await expect(page.getByText("Latest attempt failed", { exact: true })).toBeVisible(); + await expect(page.locator(".session-runtime-error")).toContainText( + "You can send another message to start a new Turn in this Session.", + ); + + const composer = page.getByLabel("Message the Agent"); + await expect(composer).toBeEnabled(); + await composer.fill("Continue after the failed Turn"); + const send = page.getByRole("button", { name: "Send message" }); + await expect(send).toBeEnabled(); + await send.click(); + + await expect.poll(async () => (await fixtureRequests(request)).filter((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot/events" + )).length).toBe(1); + const submission = (await fixtureRequests(request)).find((entry) => ( + entry.method === "POST" && entry.path === "/v1/agents/sessions/session_snapshot/events" + )); + expect(submission?.body).toEqual({ + events: [{ + type: "agent.session.input.message", + input: [{ + role: "user", + content: [{ type: "input_text", text: "Continue after the failed Turn" }], + }], + }], + }); +}); + for (const pendingRead of [ { label: "Session", @@ -2139,14 +2175,15 @@ test("presents Dashboard page-chain results and System boundaries without extra const dashboard = page.locator(".dashboard-page"); await expect(dashboard.getByRole("heading", { name: /Dashboard/ })).toBeVisible(); - await expect(dashboard).toContainText("last successfully traversed Agent and Session page-chain results"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Loaded Agents" })).toContainText("3"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Loaded Sessions" })).toContainText("1"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Reported aggregate tokens" })).toContainText("Unknown"); - await expect( - dashboard.getByRole("list", { name: "Loaded Session status counts" }).getByRole("listitem").filter({ hasText: "Idle" }), - ).toContainText("1"); - await expect(dashboard).toContainText("No Sessions in the loaded result require attention."); + await expect(dashboard).toContainText("Latest complete paginated reads"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("1"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "In progress" })).toContainText("0"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Needs attention" })).toContainText("0"); + await expect(dashboard).not.toContainText("Reported aggregate tokens"); + await expect(dashboard).toContainText("No Sessions currently need attention."); + await expect(dashboard.getByRole("button", { name: /Create agent/ })).toBeVisible(); + await expect(dashboard.getByRole("button", { name: /Start session/ })).toBeVisible(); const before = await fixtureRequests(request); const count = (entries: FixtureRequest[], path: string) => entries.filter((entry) => ( @@ -2169,6 +2206,35 @@ test("presents Dashboard page-chain results and System boundaries without extra for (const path of detailPaths) expect(count(after, path)).toBe(count(before, path)); await attachScreenshot(page, testInfo, "desktop-dashboard-loaded-snapshot"); + await page.setViewportSize({ width: 390, height: 844 }); + const dashboardBounds = await dashboard.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewportWidth: innerWidth, + documentWidth: document.documentElement.scrollWidth, + bodyWidth: document.body.scrollWidth, + left: box.left, + right: box.right, + }; + }); + expect(dashboardBounds.documentWidth).toBeLessThanOrEqual(dashboardBounds.viewportWidth); + expect(dashboardBounds.bodyWidth).toBeLessThanOrEqual(dashboardBounds.viewportWidth); + expect(dashboardBounds.left).toBeGreaterThanOrEqual(0); + expect(dashboardBounds.right).toBeLessThanOrEqual(390); + await expect(dashboard.getByRole("button", { name: /Create agent/ })).toBeVisible(); + await expect(dashboard.getByRole("button", { name: /Start session/ })).toBeVisible(); + await attachScreenshot(page, testInfo, "narrow-dashboard-loaded-snapshot"); + await page.setViewportSize({ width: 1280, height: 720 }); + + await dashboard.getByRole("button", { name: /Create agent/ }).click(); + await expect(page.locator(".agent-setup-page").getByRole("heading", { name: "New Agent" })).toBeVisible(); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + await dashboard.getByRole("button", { name: /Start session/ }).click(); + const sessionDialog = page.getByRole("dialog", { name: "Create a Session" }); + await expect(sessionDialog).toBeVisible(); + await sessionDialog.getByRole("button", { name: "Cancel" }).click(); + await page.getByRole("button", { name: "Dashboard", exact: true }).click(); + await dashboard.getByRole("table", { name: "Recent Sessions" }).getByRole("button", { name: "Lifecycle Agent" }).click(); await expect(page.locator(".session-page")).toBeVisible(); await expect(page.getByText("Lifecycle Agent", { exact: true }).first()).toBeVisible(); @@ -2281,8 +2347,8 @@ test("publishes Dashboard counts only after every top-level Agent and Session pa await page.goto("/"); await page.getByRole("button", { name: "Dashboard", exact: true }).click(); const dashboard = page.locator(".dashboard-page"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Loaded Agents" })).toContainText("3"); - await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Loaded Sessions" })).toContainText("2"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3"); + await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("2"); expect(agentAfters).toEqual([null, "agent_b"]); expect(sessionAfters).toEqual([null, "session_snapshot"]); }); @@ -2293,7 +2359,7 @@ test("keeps the previous Dashboard result when pagination exceeds the safety lim await page.getByRole("button", { name: "Dashboard", exact: true }).click(); const dashboard = page.locator(".dashboard-page"); - const loadedAgents = dashboard.locator(".dashboard-summary > div").filter({ hasText: "Loaded Agents" }); + const loadedAgents = dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" }); await expect(loadedAgents).toContainText("3"); let template: Record | null = null; @@ -2327,9 +2393,13 @@ test("keeps the previous Dashboard result when pagination exceeds the safety lim await refresh.click(); await expect.poll(() => reads).toBe(100); await expect(refresh).toBeEnabled(); - await expect(dashboard).toContainText("Refresh failed · last loaded result remains visible"); + await expect(dashboard).toContainText("Using the last successful snapshot"); await expect(dashboard).toContainText("collection pagination exceeded the Web safety limit"); await expect(loadedAgents).toContainText("3"); + await dashboard.getByRole("button", { name: "Connection settings" }).click(); + const connectionDialog = page.getByRole("dialog", { name: "Connect an Agent Core" }); + await expect(connectionDialog).toBeVisible(); + await connectionDialog.getByRole("button", { name: "Cancel" }).click(); expect(reads).toBe(100); }); diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 2217c57..59d2760 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -574,10 +574,11 @@ function emitTurnLifecycle(status) { } function emitSessionLifecycle(status) { - if (!["in_progress", "idle"].includes(status)) return false; + if (!["in_progress", "idle", "failed"].includes(status)) return false; const session = state.sessions.find((candidate) => candidate.id === "session_snapshot"); if (!session) return false; session.status = status; + session.error = status === "failed" ? "The execution could not complete." : null; session.required_actions = []; state.sequence += 1; const event = `id: session_${state.sequence}\ndata: ${JSON.stringify({ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 91140a5..9b0d2b3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2036,6 +2036,11 @@ export function App() { sessionCollectionError={sessionCollectionError} sessionCollectionHasSnapshot={sessionCollectionHasSnapshot} onRefresh={refreshDashboard} + onCreateAgent={openAgentSetup} + onStartSession={() => openSessionSetup()} + onViewAgents={() => setView("agents")} + onViewSessions={() => setView("sessions")} + onConfigureConnection={() => setConnectionOpen(true)} onOpenSession={(sessionId) => { changeSessionAgentFilter(null); selectedIdRef.current = sessionId; diff --git a/apps/web/src/components/ConnectionModal.tsx b/apps/web/src/components/ConnectionModal.tsx index 00571a4..4507dcd 100644 --- a/apps/web/src/components/ConnectionModal.tsx +++ b/apps/web/src/components/ConnectionModal.tsx @@ -309,8 +309,8 @@ export function ConnectionModal({
Operator-owned setup

- Core authentication and execution stay outside this browser. Use the operator guide for proxy, - caller-key, daemon, and provider setup. + This Web can configure and test an existing connection, but it does not start Docker or host + processes. Use the operator guide for Core, proxy, caller-key, daemon, executor, and provider setup.

diff --git a/apps/web/src/features/CoreCollectionStates.test.tsx b/apps/web/src/features/CoreCollectionStates.test.tsx index 9771171..06eaa09 100644 --- a/apps/web/src/features/CoreCollectionStates.test.tsx +++ b/apps/web/src/features/CoreCollectionStates.test.tsx @@ -271,9 +271,55 @@ describe("Agent Core collection states", () => { expect(refreshFailed).toContain("Existing durable item"); expect(refreshFailed).toContain("refresh failed"); expect(refreshFailed).toContain("last loaded Items remain visible"); - expect(sessionFailed).toContain("Session failed"); + expect(sessionFailed).toContain("Latest attempt failed"); + expect(sessionFailed).toContain("You can send another message to start a new Turn in this Session."); expect(sessionFailed).toContain("model execution failed"); expect(sessionFailed).not.toContain("Session is ready"); + expect(sessionFailed.match(/]*aria-label="Message the Agent"[^>]*>/)?.[0]).not.toContain("disabled"); + expect(sessionFailed).not.toContain("This Session has failed"); + }); + + it("blocks new messages only when the selected Session has a proven terminal Environment", () => { + const environmentId = "0f745b0d-b545-49cd-8d7e-4c31c80dc564"; + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Session cannot continue"); + expect(html).toContain("Start a new Session to continue"); + expect(html.match(/]*aria-label="Message the Agent"[^>]*>/)?.[0]).toContain("disabled"); }); it.each([ diff --git a/apps/web/src/features/dashboard/DashboardView.css b/apps/web/src/features/dashboard/DashboardView.css index c74d279..bbf11f9 100644 --- a/apps/web/src/features/dashboard/DashboardView.css +++ b/apps/web/src/features/dashboard/DashboardView.css @@ -5,406 +5,660 @@ overflow: hidden; } +.dashboard-header > div:first-child { + min-width: 0; +} + +.dashboard-header h1 { + margin: 0; +} + +.dashboard-header p { + margin: 3px 0 0; + color: var(--fg-muted); + font-size: 12px; + line-height: 16px; +} + .dashboard-scroll { min-height: 0; flex: 1; + padding: 20px 24px 40px; overflow-y: auto; } -.dashboard-intro { - padding: 24px; +.dashboard-overview, +.dashboard-panel { + min-width: 0; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 10px; + box-shadow: var(--shadow-control); } -.dashboard-intro h2, -.dashboard-collection-section > h2, -.dashboard-section > header h2 { - margin: 0; - color: var(--fg); - font-size: 16px; - font-weight: 500; - line-height: 24px; +.dashboard-snapshot-bar { + display: flex; + min-height: 52px; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + gap: 14px; } -.dashboard-intro p, -.dashboard-section > header p { - max-width: 760px; - margin: 4px 0 0; - color: var(--fg-muted); - font-size: 13px; - line-height: 19px; +.dashboard-snapshot-copy, +.dashboard-source-badge, +.dashboard-source-badges, +.dashboard-attention-title, +.dashboard-session-meta, +.dashboard-text-action { + display: flex; + align-items: center; } -.dashboard-collection-section { - border-top: 1px solid var(--line); - border-bottom: 1px solid var(--line); +.dashboard-snapshot-copy { + min-width: 0; + gap: 9px; } -.dashboard-collection-section > h2, -.dashboard-section > header { - padding: 18px 24px 10px; +.dashboard-snapshot-copy > span:last-child { + display: grid; + min-width: 0; + gap: 1px; } -.dashboard-collection-section > h2 { - font-size: 13px; - line-height: 18px; +.dashboard-snapshot-copy strong { + font-size: 12px; + font-weight: 550; + line-height: 17px; } -.dashboard-collection-states { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - border-top: 1px solid var(--line); +.dashboard-snapshot-copy small { + overflow: hidden; + color: var(--fg-muted); + font-size: 10px; + line-height: 14px; + text-overflow: ellipsis; + white-space: nowrap; } -.dashboard-collection-state { - display: grid; - min-width: 0; - min-height: 48px; - grid-template-columns: 14px minmax(74px, auto) minmax(0, 1fr); - align-items: center; - padding: 8px 24px; - gap: 4px 8px; - font-size: 12px; - line-height: 16px; +.dashboard-source-badges { + flex: 0 0 auto; + gap: 7px; } -.dashboard-collection-state + .dashboard-collection-state { - border-left: 1px solid var(--line); +.dashboard-source-badge { + min-height: 28px; + padding: 4px 8px; + gap: 5px; + color: var(--fg-muted); + font-size: 10px; + line-height: 14px; + background: var(--surface-subtle); + border: 1px solid var(--line); + border-radius: 999px; } -.dashboard-collection-state strong { +.dashboard-source-badge strong { color: var(--fg); font-weight: 500; } -.dashboard-collection-state > span:not(.status-icon) { +.dashboard-snapshot-error { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + gap: 12px; + color: var(--danger); + font-size: 11px; + line-height: 16px; + background: color-mix(in srgb, var(--danger) 6%, var(--surface)); + border-top: 1px solid color-mix(in srgb, var(--danger) 22%, var(--line)); +} + +.dashboard-snapshot-error-copy, +.dashboard-connection-action { + display: flex; + align-items: center; +} + +.dashboard-snapshot-error-copy { min-width: 0; - overflow: hidden; - color: var(--fg-muted); - text-overflow: ellipsis; - white-space: nowrap; + gap: 7px; +} + +.dashboard-snapshot-error-copy svg { + flex: 0 0 auto; } -.dashboard-collection-state small { - grid-column: 2 / -1; +.dashboard-connection-action { + flex: 0 0 auto; + padding: 4px 6px; + gap: 5px; color: var(--danger); - overflow-wrap: anywhere; + font-size: 11px; + font-weight: 550; + line-height: 16px; + background: transparent; + border: 0; + border-radius: 4px; +} + +.dashboard-connection-action:hover, +.dashboard-connection-action:focus-visible { + background: color-mix(in srgb, var(--danger) 9%, transparent); +} + +.dashboard-connection-action:focus-visible { + outline: 2px solid color-mix(in srgb, var(--danger) 45%, transparent); + outline-offset: 1px; } .dashboard-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; - border-bottom: 1px solid var(--line); + border-top: 1px solid var(--line); } -.dashboard-summary > div { +.dashboard-metric { display: flex; min-width: 0; - min-height: 112px; + min-height: 98px; flex-direction: column; justify-content: center; - padding: 16px 24px; + padding: 14px 16px; } -.dashboard-summary > div + div { +.dashboard-metric + .dashboard-metric { border-left: 1px solid var(--line); } -.dashboard-summary dt { +.dashboard-metric dt { color: var(--fg-muted); font-size: 11px; font-weight: 500; line-height: 15px; - text-transform: uppercase; - letter-spacing: 0.035em; } -.dashboard-summary dd { - margin: 5px 0 0; +.dashboard-metric dd { + margin: 4px 0 0; color: var(--fg); font-family: var(--font-mono); - font-size: 24px; + font-size: 25px; font-variant-numeric: tabular-nums slashed-zero; font-weight: 500; - line-height: 30px; + line-height: 31px; overflow-wrap: anywhere; } -.dashboard-summary small { - margin-top: 4px; +.dashboard-metric small { + margin-top: 2px; color: var(--fg-muted); - font-size: 11px; - line-height: 15px; + font-size: 10px; + line-height: 14px; } -.dashboard-section { - padding-bottom: 8px; - border-bottom: 1px solid var(--line); +.dashboard-metric-attention dd { + color: var(--danger); } -.dashboard-section > header { +.dashboard-primary-grid { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.75fr); + margin-top: 16px; + gap: 16px; +} + +.dashboard-panel > header { display: flex; - min-width: 0; - align-items: baseline; + min-height: 60px; + align-items: center; justify-content: space-between; - gap: 16px; + padding: 12px 14px; + gap: 14px; + border-bottom: 1px solid var(--line); } -.dashboard-section > header h2 { - flex: 0 0 auto; +.dashboard-panel > header > div { + min-width: 0; +} + +.dashboard-panel h2, +.dashboard-panel header p { + margin: 0; +} + +.dashboard-panel h2 { font-size: 14px; + font-weight: 550; line-height: 20px; } -.dashboard-section > header p { - max-width: none; - margin: 0; - text-align: right; +.dashboard-panel header p { + margin-top: 2px; + overflow: hidden; + color: var(--fg-muted); + font-size: 10px; + line-height: 14px; + text-overflow: ellipsis; + white-space: nowrap; } -.dashboard-status-ledger { +.dashboard-count-badge { + display: inline-flex; + min-width: 24px; + height: 24px; + align-items: center; + justify-content: center; + padding: 0 7px; + color: var(--danger); + font-family: var(--font-mono); + font-size: 11px; + background: color-mix(in srgb, var(--danger) 9%, var(--surface)); + border-radius: 999px; +} + +.dashboard-attention-list, +.dashboard-quick-list { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - border-top: 1px solid var(--line); } -.dashboard-status-ledger > div { - display: flex; +.dashboard-attention-item, +.dashboard-quick-action { + display: grid; + width: 100%; min-width: 0; - height: 38px; + color: var(--fg); + text-align: left; + background: transparent; + border: 0; + border-bottom: 1px solid var(--line); + transition: background-color 150ms var(--ease-settle); +} + +.dashboard-attention-item { + grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; - justify-content: space-between; - padding: 0 24px; + min-height: 62px; + padding: 10px 14px; gap: 10px; - color: var(--fg-muted); - font-size: 12px; - line-height: 16px; } -.dashboard-status-ledger > div + div { - border-left: 1px solid var(--line); +.dashboard-attention-item:hover:not(:disabled), +.dashboard-attention-item:focus-visible, +.dashboard-quick-action:hover, +.dashboard-quick-action:focus-visible { + background: var(--hover); } -.dashboard-status-ledger strong { - color: var(--fg); - font-family: var(--font-mono); - font-variant-numeric: tabular-nums slashed-zero; - font-weight: 500; +.dashboard-attention-item:focus-visible, +.dashboard-quick-action:focus-visible, +.dashboard-text-action:focus-visible, +.dashboard-recent-identity button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: -2px; } -.dashboard-session-ledger { - overflow-x: auto; - border-top: 1px solid var(--line); +.dashboard-attention-item:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.dashboard-status-dot { + width: 7px; + height: 7px; + background: var(--fg-muted); + border-radius: 50%; +} + +.dashboard-status-dot-failed { + background: var(--danger); } -.dashboard-session-ledger-header, -.dashboard-session-ledger-row { +.dashboard-status-dot-requires_action { + background: var(--warning); +} + +.dashboard-attention-copy { display: grid; - grid-template-columns: - minmax(190px, 1.5fr) minmax(170px, 1.25fr) minmax(106px, 0.7fr) - minmax(134px, 0.9fr) minmax(176px, 1.1fr) minmax(82px, 0.55fr); - min-width: 920px; - align-items: center; - padding: 0 24px; - gap: 10px; - border-bottom: 1px solid var(--line); + min-width: 0; + gap: 4px; } -.dashboard-session-ledger-header { - height: 28px; - color: var(--fg-muted); - font-size: 11px; - line-height: 15px; +.dashboard-attention-title { + min-width: 0; + gap: 8px; } -.dashboard-session-ledger-row { - min-height: 48px; - color: var(--fg); +.dashboard-attention-title strong { + min-width: 0; + overflow: hidden; font-size: 12px; + font-weight: 550; line-height: 16px; - transition: background-color 150ms var(--ease-settle); + text-overflow: ellipsis; + white-space: nowrap; } -.dashboard-session-ledger-row:hover, -.dashboard-session-ledger-row:focus-within { - background: var(--hover); +.dashboard-status-pill { + display: inline-flex; + width: fit-content; + max-width: 100%; + min-height: 20px; + align-items: center; + padding: 2px 7px; + overflow: hidden; + color: var(--fg-muted); + font-size: 10px; + line-height: 14px; + background: var(--surface-subtle); + border-radius: 999px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-status-pill-failed { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 8%, var(--surface)); +} + +.dashboard-status-pill-requires_action { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 9%, var(--surface)); } -.dashboard-session-ledger-row > span { +.dashboard-status-pill-in_progress { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--surface)); +} + +.dashboard-session-meta { min-width: 0; + gap: 5px; overflow: hidden; - text-overflow: ellipsis; + color: var(--fg-muted); + font-size: 10px; + line-height: 14px; white-space: nowrap; } -.dashboard-session-identity, -.dashboard-session-agent { +.dashboard-session-meta > span, +.dashboard-session-meta time { + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-panel > footer { display: flex; - flex-direction: column; + min-height: 40px; + align-items: center; + justify-content: flex-end; + padding: 6px 12px; } -.dashboard-session-identity button { +.dashboard-text-action { width: fit-content; - max-width: 100%; - padding: 0; - overflow: hidden; - color: var(--fg); - font-weight: 500; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; + padding: 5px 6px; + gap: 5px; + color: var(--fg-muted); + font-size: 11px; background: transparent; border: 0; - border-radius: 2px; + border-radius: 4px; +} + +.dashboard-text-action:hover { + color: var(--fg); + background: var(--hover); } -.dashboard-session-identity button:hover { +.dashboard-quick-action { + grid-template-columns: 34px minmax(0, 1fr) auto; + align-items: center; + min-height: 72px; + padding: 11px 14px; + gap: 10px; +} + +.dashboard-quick-icon { + display: inline-flex; + width: 34px; + height: 34px; + align-items: center; + justify-content: center; color: var(--accent); - text-decoration: underline; + background: color-mix(in srgb, var(--accent) 9%, var(--surface)); + border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--line)); + border-radius: 8px; +} + +.dashboard-quick-action > span:nth-child(2) { + display: grid; + min-width: 0; + gap: 2px; } -.dashboard-session-identity button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; +.dashboard-quick-action strong { + font-size: 12px; + font-weight: 550; + line-height: 17px; } -.dashboard-session-identity code, -.dashboard-session-agent code { +.dashboard-quick-action small { overflow: hidden; color: var(--fg-muted); - font-family: var(--font-mono); font-size: 10px; line-height: 14px; text-overflow: ellipsis; white-space: nowrap; } -.dashboard-session-agent strong { - overflow: hidden; - font-weight: 400; - text-overflow: ellipsis; - white-space: nowrap; +.dashboard-quick-footer { + justify-content: space-between !important; +} + +.dashboard-recent-panel { + margin-top: 16px; } -.dashboard-session-status { +.dashboard-recent-ledger { + overflow-x: auto; +} + +.dashboard-recent-header, +.dashboard-recent-row { + display: grid; + grid-template-columns: minmax(220px, 1.6fr) minmax(110px, 0.7fr) minmax(140px, 0.9fr) minmax(150px, 0.9fr); + min-width: 760px; + align-items: center; + padding: 0 14px; + gap: 12px; + border-bottom: 1px solid var(--line); +} + +.dashboard-recent-header { + height: 30px; color: var(--fg-muted); + font-size: 10px; + line-height: 14px; + background: var(--surface-subtle); } -.dashboard-session-status-in_progress { - color: var(--accent); +.dashboard-recent-row { + min-height: 50px; + color: var(--fg); + font-size: 11px; + line-height: 15px; + transition: background-color 150ms var(--ease-settle); } -.dashboard-session-status-requires_action { - color: var(--warning); +.dashboard-recent-row:hover, +.dashboard-recent-row:focus-within { + background: var(--hover); } -.dashboard-session-status-failed { - color: var(--danger); +.dashboard-recent-row > span, +.dashboard-recent-row > time { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.dashboard-session-tokens { - font-family: var(--font-mono); - font-variant-numeric: tabular-nums slashed-zero; - text-align: right; +.dashboard-recent-identity { + display: grid; + gap: 1px; +} + +.dashboard-recent-identity button { + width: fit-content; + max-width: 100%; + padding: 0; + overflow: hidden; + color: var(--fg); + font: inherit; + font-weight: 550; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + background: transparent; + border: 0; + border-radius: 2px; } -.dashboard-empty, -.dashboard-unavailable { +.dashboard-recent-identity button:hover { + color: var(--accent); +} + +.dashboard-recent-identity small { + overflow: hidden; + color: var(--fg-muted); + font-size: 9px; + line-height: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-empty { display: flex; - min-height: 48px; + min-height: 64px; align-items: center; margin: 0; - padding: 0 24px; + padding: 12px 14px; gap: 7px; color: var(--fg-muted); - font-size: 12px; + font-size: 11px; line-height: 16px; - border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); } -.dashboard-unavailable svg { - flex: 0 0 auto; - color: var(--warning); +.dashboard-empty-positive::before { + width: 7px; + height: 7px; + content: ""; + background: var(--success); + border-radius: 50%; } -.dashboard-recent { - padding-bottom: 40px; - border-bottom: 0; +@media (max-width: 980px) { + .dashboard-primary-grid { + grid-template-columns: 1fr; + } } -@media (max-width: 1000px) { +@media (max-width: 760px) { + .dashboard-scroll { + padding: 14px 12px 28px; + } + + .dashboard-snapshot-bar { + align-items: flex-start; + flex-direction: column; + } + + .dashboard-snapshot-copy small { + white-space: normal; + } + + .dashboard-source-badges { + width: 100%; + } + + .dashboard-source-badge { + flex: 1; + justify-content: center; + } + + .dashboard-snapshot-error { + align-items: flex-start; + flex-direction: column; + } + + .dashboard-connection-action { + margin-left: 19px; + } + .dashboard-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .dashboard-summary > div:nth-child(3) { + .dashboard-metric:nth-child(3) { border-left: 0; } - .dashboard-summary > div:nth-child(n + 3) { + .dashboard-metric:nth-child(n + 3) { border-top: 1px solid var(--line); } } -@media (max-width: 640px) { - .dashboard-page .page-header { +@media (max-width: 520px) { + .dashboard-header { min-height: auto; - flex: 0 0 auto; + align-items: flex-start; padding: 10px 12px; } - .dashboard-intro { - padding: 18px 12px; + .dashboard-header .page-actions { + margin-left: 8px; } - .dashboard-collection-section > h2, - .dashboard-section > header { - padding-right: 12px; - padding-left: 12px; - } - - .dashboard-collection-states, - .dashboard-summary, - .dashboard-status-ledger { + .dashboard-summary { grid-template-columns: 1fr; } - .dashboard-collection-state { - padding-right: 12px; - padding-left: 12px; + .dashboard-metric { + min-height: 78px; } - .dashboard-collection-state + .dashboard-collection-state, - .dashboard-summary > div + div, - .dashboard-status-ledger > div + div { - border-left: 0; + .dashboard-metric + .dashboard-metric, + .dashboard-metric:nth-child(3) { border-top: 1px solid var(--line); + border-left: 0; } - .dashboard-summary > div { - min-height: 96px; - padding: 14px 12px; + .dashboard-panel > header { + align-items: flex-start; } - .dashboard-section > header { - display: block; + .dashboard-panel header p { + white-space: normal; } - .dashboard-section > header p { - margin-top: 3px; - text-align: left; + .dashboard-attention-title { + align-items: flex-start; + flex-direction: column; + gap: 3px; } - .dashboard-status-ledger > div, - .dashboard-empty, - .dashboard-unavailable { - padding-right: 12px; - padding-left: 12px; + .dashboard-session-meta { + align-items: flex-start; + flex-direction: column; + gap: 1px; + white-space: normal; } - .dashboard-session-ledger-header, - .dashboard-session-ledger-row { - padding-right: 12px; - padding-left: 12px; + .dashboard-session-meta > span[aria-hidden="true"] { + display: none; } } diff --git a/apps/web/src/features/dashboard/DashboardView.test.tsx b/apps/web/src/features/dashboard/DashboardView.test.tsx index 7f4d70a..85d54e4 100644 --- a/apps/web/src/features/dashboard/DashboardView.test.tsx +++ b/apps/web/src/features/dashboard/DashboardView.test.tsx @@ -55,6 +55,11 @@ function session(id: string, overrides: Partial = {}): AgentSessio const callbacks = { onRefresh: () => undefined, + onCreateAgent: () => undefined, + onStartSession: () => undefined, + onViewAgents: () => undefined, + onViewSessions: () => undefined, + onConfigureConnection: () => undefined, onOpenSession: () => undefined, }; @@ -88,14 +93,16 @@ describe("Dashboard loaded-result presentation", () => { agentCollectionHasSnapshot: false, }); - expect(staleEmpty).toContain("Refresh failed · last loaded result remains visible"); - expect(staleEmpty).toContain("Loaded Agents"); + expect(staleEmpty).toContain("Using the last successful snapshot"); + expect(staleEmpty).toContain("Agents: refresh failed"); expect(staleEmpty).toContain(">0<"); + expect(unavailable).toContain("Snapshot incomplete"); expect(unavailable).toContain("Unavailable"); - expect(unavailable).not.toContain("Refresh failed · last loaded result remains visible"); + expect(unavailable).toContain("Connection settings"); + expect(unavailable).not.toContain("Using the last successful snapshot"); }); - it("renders qualified resource, status, Usage, and Environment facts", () => { + it("renders a compact actionable overview while preserving Environment qualifications", () => { const selfHosted: AgentSession["environment"] = { type: "self_hosted", id: "0f745b0d-b545-49cd-8d7e-4c31c80dc564", @@ -124,37 +131,36 @@ describe("Dashboard loaded-result presentation", () => { }); expect(html).toContain("Dashboard"); - expect(html).toContain("Loaded Core results"); - expect(html).toContain("last successfully traversed Agent and Session page-chain results"); - expect(html).toContain("reached Core's end marker within the 100-page safety limit"); - expect(html).toContain("not an atomic snapshot or a current Core total"); - expect(html).toContain("rejected instead of publishing its partial result"); - expect(html).toContain("Loaded Agents"); + expect(html).toContain("Agents and Sessions that may need your attention."); + expect(html).toContain("Snapshot ready"); + expect(html).toContain("Latest complete paginated reads · not a live Core total or runtime-readiness signal"); + expect(html).toContain('aria-label="Core resource snapshot"'); + expect(html).toContain("Saved definitions"); + expect(html).toContain("In this snapshot"); + expect(html).toContain("Needs attention"); expect(html).not.toContain("Session-admissible Agents"); - expect(html).toContain("Count applies only to the last loaded page-chain result, not a current Core total"); - expect(html).toContain("failed or incomplete refreshes keep the previous result"); - expect(html).toContain("Loaded 1 record · reached Core end marker"); - expect(html).toContain("Loaded 2 records · reached Core end marker"); - expect(html).toContain("1 of 2 loaded Sessions report aggregate Usage."); - expect(html).toContain("42"); expect(html).toContain("Requires action"); expect(html).toContain("Self-hosted profile"); expect(html).toContain("not proof that an executor is connected"); expect(html).toContain('aria-label="Sessions needing attention"'); expect(html).toContain('aria-label="Recent Sessions"'); - expect(html).toContain(''); - expect(html).toContain("2023-11-14 22:16:40 UTC"); + expect(html).toContain("Needs a result"); + expect(html).toContain("Waiting"); + expect(html).toContain("2023-11-14 22:16 UTC"); + expect(html).toContain("Create agent"); + expect(html).toContain("Start session"); + expect(html).not.toContain("Reported aggregate tokens"); + expect(html).not.toContain("action-session"); expect(html).not.toContain("Connected Environment"); expect(html).not.toContain("Execution ready"); }); - it("keeps absent Usage unknown instead of presenting a zero", () => { + it("keeps partial Usage out of the primary overview", () => { const html = render({ sessions: [session("usage-unknown")] }); - expect(html).toContain("Reported aggregate tokens"); - expect(html).toContain("Unknown"); - expect(html).toContain("No loaded Session reports aggregate Usage."); - expect(html).not.toContain("0 of 1 loaded Sessions report aggregate Usage."); + expect(html).not.toContain("Reported aggregate tokens"); + expect(html).not.toContain("aggregate Usage"); + expect(html).toContain("Sessions"); }); it("does not turn a failed empty collection into a zero-sized healthy snapshot", () => { @@ -170,10 +176,9 @@ describe("Dashboard loaded-result presentation", () => { expect(html).toContain("agents unavailable"); expect(html).toContain("sessions unavailable"); expect(html).toContain("Unavailable"); - expect(html).toContain("Session status is unavailable."); - expect(html).toContain("Recent Sessions are unavailable."); - expect(html).not.toContain("Loaded 0 records"); - expect(html).not.toContain('aria-label="Loaded Session status counts"'); + expect(html).toContain("Session snapshot unavailable."); + expect(html).toContain("Recent Sessions unavailable."); + expect(html).not.toContain('aria-label="Recent Sessions"'); }); it("retains stale loaded facts while making refresh failures explicit", () => { @@ -186,12 +191,13 @@ describe("Dashboard loaded-result presentation", () => { sessionCollectionError: "Session refresh failed", }); - expect(html.match(/Refresh failed · last loaded result remains visible/g)).toHaveLength(2); + expect(html).toContain("Using the last successful snapshot"); + expect(html.match(/>Stale { @@ -202,7 +208,8 @@ describe("Dashboard loaded-result presentation", () => { sessionCollectionState: "connecting", }); - expect(html.match(/Refreshing · last loaded result remains visible/g)).toHaveLength(2); + expect(html).toContain("Refreshing snapshot"); + expect(html.match(/>Refreshing void; + onCreateAgent: () => void; + onStartSession: () => void; + onViewAgents: () => void; + onViewSessions: () => void; + onConfigureConnection: () => void; onOpenSession: (sessionId: string) => void; } @@ -37,41 +49,43 @@ function collectionStatusKind(state: DashboardCollectionState): StatusKind { return "running"; } -function CollectionStateRow({ +function collectionStateLabel(state: DashboardCollectionState, hasSnapshot: boolean): string { + if (state === "ready") return "Ready"; + if (state === "connecting") return hasSnapshot ? "Refreshing" : "Loading"; + return hasSnapshot ? "Stale" : "Unavailable"; +} + +function CollectionStateBadge({ label, state, - error, - count, hasSnapshot, }: { label: string; state: DashboardCollectionState; - error: string | null; - count: number; hasSnapshot: boolean; }) { - const snapshotAvailable = collectionHasSnapshot(state, hasSnapshot); - const description = state === "ready" - ? `Loaded ${count.toLocaleString("en-US")} record${count === 1 ? "" : "s"} · reached Core end marker` - : state === "connecting" - ? snapshotAvailable ? "Refreshing · last loaded result remains visible" : "Loading from Agent Core" - : snapshotAvailable ? "Refresh failed · last loaded result remains visible" : "Unavailable"; return ( -
+ {label} - {description} - {state === "failed" ? {error || "The Agent Core collection request failed."} : null} -
+ {collectionStateLabel(state, hasSnapshot)} + ); } -function Metric({ label, value, detail }: { label: string; value: string; detail: string }) { +function Metric({ + label, + value, + detail, + emphasis = false, +}: { + label: string; + value: string; + detail: string; + emphasis?: boolean; +}) { return ( -
+
{label}
{value}
{detail} @@ -79,28 +93,106 @@ function Metric({ label, value, detail }: { label: string; value: string; detail ); } -function SessionLedger({ - label, +function SessionMeta({ session }: { session: DashboardSessionRow }) { + return ( + + {session.agentLabel} + + + {dashboardEnvironmentLabel(session.environmentProfile)} + + + + + ); +} + +function AttentionList({ sessions, onOpenSession, }: { - label: string; sessions: readonly DashboardSessionRow[]; onOpenSession: (sessionId: string) => void; }) { return ( -
-
+
+ {sessions.map((session, index) => ( +
+ +
+ ))} +
+ ); +} + +function QuickAction({ + icon, + title, + description, + onClick, +}: { + icon: ReactNode; + title: string; + description: string; + onClick: () => void; +}) { + return ( + + ); +} + +function RecentSessions({ + sessions, + onOpenSession, +}: { + sessions: readonly DashboardSessionRow[]; + onOpenSession: (sessionId: string) => void; +}) { + return ( +
+
Session - Agent Status Environment Last active - Tokens
{sessions.map((session, index) => ( -
- +
+ - {session.id ?? "Unavailable"} - - - {session.agentLabel} - {session.model} + {session.agentLabel} - - {dashboardStatusLabel(session.status)} + + + {dashboardStatusLabel(session.status)} + - {dashboardEnvironmentLabel(session.environmentProfile)} + - - {session.lastActiveAt === null ? "Unknown" : ( - - )} - - - {session.totalTokens === null ? "Unknown" : session.totalTokens.toLocaleString("en-US")} - + {formatDashboardTimestamp(session.lastActiveAt)} +
))}
@@ -155,29 +232,42 @@ export function DashboardView({ sessionCollectionError, sessionCollectionHasSnapshot, onRefresh, + onCreateAgent, + onStartSession, + onViewAgents, + onViewSessions, + onConfigureConnection, onOpenSession, }: DashboardViewProps) { - const snapshot = useMemo(() => buildDashboardSnapshot(agents, sessions), [agents, sessions]); + const snapshot = useMemo(() => buildDashboardSnapshot(agents, sessions, 6, 5), [agents, sessions]); const agentsAvailable = collectionHasSnapshot(agentCollectionState, agentCollectionHasSnapshot); const sessionsAvailable = collectionHasSnapshot(sessionCollectionState, sessionCollectionHasSnapshot); const refreshing = agentCollectionState === "connecting" || sessionCollectionState === "connecting"; - const usageDetail = !sessionsAvailable - ? "Session page-chain result unavailable." - : snapshot.usage.reportedSessionCount === 0 - ? "No loaded Session reports aggregate Usage." - : `${snapshot.usage.reportedSessionCount.toLocaleString("en-US")} of ${snapshot.loadedSessionCount.toLocaleString("en-US")} loaded Sessions report aggregate Usage.`; - const statusEntries = [ - ["Idle", snapshot.statusCounts.idle], - ["In progress", snapshot.statusCounts.in_progress], - ["Requires action", snapshot.statusCounts.requires_action], - ["Failed", snapshot.statusCounts.failed], - ...(snapshot.statusCounts.unknown > 0 ? [["Unavailable", snapshot.statusCounts.unknown]] : []), - ] as const; + const hasStaleSnapshot = ( + (agentCollectionState === "failed" && agentCollectionHasSnapshot) || + (sessionCollectionState === "failed" && sessionCollectionHasSnapshot) + ); + const hasUnavailableSource = !agentsAvailable || !sessionsAvailable; + const attentionCount = snapshot.statusCounts.requires_action + snapshot.statusCounts.failed; + const sourceErrors: Array = [ + agentCollectionState === "failed" ? ["Agents", agentCollectionError] as const : null, + sessionCollectionState === "failed" ? ["Sessions", sessionCollectionError] as const : null, + ].filter((entry): entry is readonly ["Agents" | "Sessions", string | null] => entry !== null); + const snapshotTitle = hasUnavailableSource + ? "Snapshot incomplete" + : hasStaleSnapshot + ? "Using the last successful snapshot" + : refreshing + ? "Refreshing snapshot" + : "Snapshot ready"; return (
-
-

Dashboard Loaded Core results

+
+
+

Dashboard

+

Agents and Sessions that may need your attention.

+
+
+ ) : null} + +
+ + + + 0} /> -
+ -
- - - -
+
+
+
+
+

Needs attention

+

Most recent Sessions requiring action or reporting failure.

+
+ {sessionsAvailable && attentionCount > 0 ? {attentionCount} : null} +
+ {!sessionsAvailable ? ( +

+ ) : snapshot.attentionSessions.length ? ( + + ) : ( +

No Sessions currently need attention.

+ )} +
+ +
+
-
-
-

Session status

-

Exact statuses from the loaded Session page-chain result; Idle is not relabelled as Completed.

-
- {sessionsAvailable ? ( -
- {statusEntries.map(([label, count]) => ( -
- {label} - {count.toLocaleString("en-US")} -
- ))} +
+
+
+

Quick actions

+

Move directly into the two common workflows.

+
+
+
+ } title="Create agent" description="Start from a blank definition or template." onClick={onCreateAgent} /> + } title="Start session" description="Choose a saved Agent and Environment profile." onClick={onStartSession} />
- ) : ( -

- )} -
- -
-
-

Needs attention

-

Loaded Sessions that require an action or report a failed status.

-
- {!sessionsAvailable ? ( -

- ) : snapshot.attentionSessions.length ? ( - - ) : ( -

No Sessions in the loaded result require attention.

- )} -
+
+ + +
+
+
-
+
-

Recent Sessions

-

Up to eight loaded Sessions ordered by valid Core-reported last activity.

+
+

Recent activity

+

Latest Sessions that are not already listed under Needs attention.

+
+
{!sessionsAvailable ? ( -

+

) : snapshot.recentSessions.length ? ( - + ) : ( -

No Sessions in the loaded result.

+

)}
diff --git a/apps/web/src/features/dashboard/dashboard-model.test.ts b/apps/web/src/features/dashboard/dashboard-model.test.ts index 9a31f70..e638c88 100644 --- a/apps/web/src/features/dashboard/dashboard-model.test.ts +++ b/apps/web/src/features/dashboard/dashboard-model.test.ts @@ -112,7 +112,7 @@ describe("Dashboard loaded-snapshot model", () => { }); expect(snapshot.attentionSessions.map((row) => row.id)).toEqual(["action", "failed"]); expect(snapshot.recentSessions.map((row) => row.id)).toEqual([ - "progress", "action", "failed", "future", "idle", + "progress", "future", "idle", ]); expect(dashboardStatusLabel("idle")).toBe("Idle"); expect(dashboardStatusLabel("unknown")).toBe("Unavailable"); @@ -201,7 +201,7 @@ describe("Dashboard loaded-snapshot model", () => { ]); expect(snapshot.recentSessions.map((row) => row.id)).toEqual(["known-time", "unknown-time"]); - expect(formatDashboardTimestamp(1_700_000_000)).toBe("2023-11-14 22:13:20 UTC"); + expect(formatDashboardTimestamp(1_700_000_000)).toBe("2023-11-14 22:13 UTC"); expect(formatDashboardTimestamp(-1)).toBe("Unknown"); expect(formatDashboardTimestamp(null)).toBe("Unknown"); }); diff --git a/apps/web/src/features/dashboard/dashboard-model.ts b/apps/web/src/features/dashboard/dashboard-model.ts index 874a2d9..2239bce 100644 --- a/apps/web/src/features/dashboard/dashboard-model.ts +++ b/apps/web/src/features/dashboard/dashboard-model.ts @@ -199,7 +199,9 @@ export function buildDashboardSnapshot( attentionSessions: rows .filter((session) => session.status === "requires_action" || session.status === "failed") .slice(0, Math.max(0, attentionLimit)), - recentSessions: rows.slice(0, Math.max(0, recentLimit)), + recentSessions: rows + .filter((session) => session.status !== "requires_action" && session.status !== "failed") + .slice(0, Math.max(0, recentLimit)), }; } @@ -225,5 +227,5 @@ export function dashboardEnvironmentLabel(profile: DashboardEnvironmentProfile): export function formatDashboardTimestamp(value: number | null): string { const seconds = canonicalTimestamp(value); if (seconds === null) return "Unknown"; - return `${new Date(seconds * 1_000).toISOString().slice(0, 19).replace("T", " ")} UTC`; + return `${new Date(seconds * 1_000).toISOString().slice(0, 16).replace("T", " ")} UTC`; } diff --git a/apps/web/src/features/sessions/SessionsView.tsx b/apps/web/src/features/sessions/SessionsView.tsx index 74a6e7b..617bc56 100644 --- a/apps/web/src/features/sessions/SessionsView.tsx +++ b/apps/web/src/features/sessions/SessionsView.tsx @@ -380,10 +380,38 @@ export function SessionsView({ setPendingMessage((current) => current === pendingMessage ? null : current); }, [items, pendingMessage, selected?.id]); + const requiredActionsValue: unknown = selected?.required_actions; + const requiredActionsAreValid = Array.isArray(requiredActionsValue); + const requiredActions: unknown[] = requiredActionsAreValid ? requiredActionsValue : []; + const environmentConnections = requiredActions.filter(isEnvironmentConnectionAction); + const functionActions = requiredActions.filter(isFunctionCallAction); + const unsupportedActionCount = requiredActions.length - environmentConnections.length - functionActions.length + ( + !requiredActionsAreValid || selected?.status === "requires_action" && !requiredActions.length ? 1 : 0 + ); + const showCancelOnly = Boolean( + (selected?.status === "in_progress" || selected?.status === "requires_action") && + (unsupportedActionCount > 0 || environmentConnections.length > 0 && functionActions.length === 0), + ); + const environmentPresentation = selected + ? resolveEnvironmentPresentation(selected.environment, environmentObservation, environmentConnections) + : null; + const inputBlockedByTerminalEnvironment = Boolean( + environmentPresentation?.visible && + (environmentPresentation.status === "failed" || environmentPresentation.status === "expired"), + ); + const send = async (event?: FormEvent) => { event?.preventDefault(); const value = message.trim(); - if (sendingRef.current || busy || !value || !selected || detailState !== "ready" || streamState !== "listening") return; + if ( + sendingRef.current || + busy || + !value || + !selected || + detailState !== "ready" || + streamState !== "listening" || + inputBlockedByTerminalEnvironment + ) return; const sessionId = selected.id; const pending = beginLocalPendingMessage(sessionId, value, items); sendingRef.current = true; @@ -424,22 +452,6 @@ export function SessionsView({ void onCancel().catch(() => undefined); }; - const requiredActionsValue: unknown = selected?.required_actions; - const requiredActionsAreValid = Array.isArray(requiredActionsValue); - const requiredActions: unknown[] = requiredActionsAreValid ? requiredActionsValue : []; - const environmentConnections = requiredActions.filter(isEnvironmentConnectionAction); - const functionActions = requiredActions.filter(isFunctionCallAction); - const unsupportedActionCount = requiredActions.length - environmentConnections.length - functionActions.length + ( - !requiredActionsAreValid || selected?.status === "requires_action" && !requiredActions.length ? 1 : 0 - ); - const showCancelOnly = Boolean( - (selected?.status === "in_progress" || selected?.status === "requires_action") && - (unsupportedActionCount > 0 || environmentConnections.length > 0 && functionActions.length === 0), - ); - const environmentPresentation = selected - ? resolveEnvironmentPresentation(selected.environment, environmentObservation, environmentConnections) - : null; - const onViewTabKeyDown = (event: KeyboardEvent) => { const tabs = Array.from(event.currentTarget.parentElement?.querySelectorAll("[role=tab]") ?? []); const index = tabs.indexOf(event.currentTarget); @@ -743,8 +755,10 @@ export function SessionsView({ {selected.status === "failed" ? ( ) : null} @@ -809,10 +823,12 @@ export function SessionsView({ element.style.height = `${Math.min(element.scrollHeight, 200)}px`; }} onKeyDown={onComposerKeyDown} - placeholder={selected.status === "failed" ? "This Session has failed" : `Message ${selected.agent.name || "the Agent"}…`} + placeholder={inputBlockedByTerminalEnvironment + ? "Start a new Session to continue" + : `Message ${selected.agent.name || "the Agent"}…`} aria-label="Message the Agent" rows={1} - disabled={detailState !== "ready" || selected.status === "failed"} + disabled={detailState !== "ready" || inputBlockedByTerminalEnvironment} />
@@ -827,7 +843,7 @@ export function SessionsView({ type="submit" aria-label="Send message" title="Send message" - disabled={busy || detailState !== "ready" || !message.trim() || selected.status === "failed" || streamState !== "listening"} + disabled={busy || detailState !== "ready" || !message.trim() || inputBlockedByTerminalEnvironment || streamState !== "listening"} > diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md index 4304393..c63cadf 100644 --- a/docs/protocol-coverage.md +++ b/docs/protocol-coverage.md @@ -50,7 +50,7 @@ the Core key binding. Agents Core Web's local proxy owns the bearer server-side. | Session-only Agent overrides | Yes | Yes, finite | Whole-field overrides can replace `model`, set/clear `instructions`, replace the supported plain-text configuration, reset saved-only `multi_agent`, `reasoning`, or `service_tier` values to Core defaults, and inherit, clear, or fully replace `tools` through the bounded Function/HTTP MCP editor. Untouched fields are omitted; there is no arbitrary Agent JSON or patch-style partial Tool update | | Sessions update/delete | Yes | Yes | Title/string metadata editing and one-Session confirmed deletion; no bulk or Workspace deletion | | Session live events | Yes | Yes | Authenticated `fetch` stream, not `EventSource`; reconciles durable state, projects exact text and command-output deltas, and treats an in-band `error` as a retryable interruption rather than conversation data | -| Input message / steering | Yes | Yes | Opens SSE before submitting `agent.session.input.message`; only HTTP 204 is durable admission, while Core errors or an unexpected 2xx remain visible and uncertain failures retain the in-memory payload/key for an explicit unchanged manual retry only | +| Input message / steering | Yes | Yes | Opens SSE before submitting `agent.session.input.message`; a Session `failed` projection from its latest failed input/Turn can accept a later message that starts a new Turn, while an exactly observed terminal `failed`/`expired` Environment keeps input disabled. Only HTTP 204 is durable admission; Core errors or an unexpected 2xx remain visible and uncertain failures retain the in-memory payload/key for an explicit unchanged manual retry only | | Active Turn cancel | Yes | Yes | Submitted as a Session event, not a Turn-create endpoint | | Turn list | Yes | Yes, read-only | Selected Sessions load every page in ascending creation order; no Turn mutation UI | | Turn retrieve | Yes | No | Reusable client diagnostic method; timeline recovery uses the all-pages list |