diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md index ebac1c947..6562db442 100644 --- a/tools/web-ux/README.md +++ b/tools/web-ux/README.md @@ -183,9 +183,11 @@ approved immutable artifact channel when durable review evidence is required. ## Workspace Home fixture -`scenarios/workspace-home.json` captures the production Home with owner, non-owner, empty-resource, -and long-name Workspace projections at 1440px, 768px, and 390px. It uses no real account or private -Workspace data. Build `web/workspace` first, then run from `tools/web-ux`: +`scenarios/workspace-home.json` captures the production work dashboard with owner, non-owner, +long-name, empty, and intentionally failing-review Workspace projections at 1440px, 768px, and 390px. +The partial-error route deliberately returns HTTP 503 only for Merge Requests; captures should retain +that request/console/visible error while showing successful active-work and recent-update feeds. +It uses no real account or private Workspace data. Build `web/workspace` first, then run from `tools/web-ux`: ```sh deno task web-ux capture --scenario scenarios/workspace-home.json --output ../../target/web-ux --run-id home-review @@ -193,7 +195,8 @@ deno task web-ux capture --scenario scenarios/workspace-home.json --output ../.. The scenario owns a fixture server on localhost port 15281 and stops only that server. The production Home browser regression (`browser-tests/workspace_home_shell_test.ts`, included in `test:browser`) -also checks 320px, both themes, keyboard navigation, permission-gated links, and absence of Host requests. +also checks 320px, both themes, keyboard record navigation, bounded loading, independent slow/error +feeds, explicit refresh, and absence of Host requests. ## Adding a scenario diff --git a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts index 71a5e1b21..d66e0ece8 100644 --- a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts +++ b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts @@ -1,4 +1,5 @@ import { extname, join, normalize } from "jsr:@std/path@1.1.4"; +import { dashboardFixture } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts"; const port = Number(Deno.args[0]); const buildRoot = Deno.args[1]; @@ -6,6 +7,8 @@ if (!Number.isInteger(port) || !buildRoot) throw new Error("usage: server const names: Record = { "home-owner": "Workspace Home Review", "home-member": "Shared Workspace", + "home-empty": "New Workspace", + "home-error": "Partial failure Workspace", "home-long": "Workspace with a long name — international documentation and distributed development", }; @@ -38,6 +41,11 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (match) { const [, workspaceId, path] = match; const owner = workspaceId !== "home-member"; + if (workspaceId === "home-error" && path === "/merge-requests") { + return json({ error: "fixture unavailable" }, 503); + } + const dashboardData = dashboardFixture(path, url.searchParams, workspaceId); + if (dashboardData) return json(dashboardData); if (path === "/workspace") { return json({ workspace_id: workspaceId, diff --git a/tools/web-ux/browser-tests/workspace_home_shell_test.ts b/tools/web-ux/browser-tests/workspace_home_shell_test.ts index 1f2b27a78..19ae707eb 100644 --- a/tools/web-ux/browser-tests/workspace_home_shell_test.ts +++ b/tools/web-ux/browser-tests/workspace_home_shell_test.ts @@ -1,11 +1,15 @@ /// import { assert, assertEquals } from "@std/assert"; import { dirname, fromFileUrl, join } from "@std/path"; -import { chromium } from "playwright"; +import { chromium, type Page } from "playwright"; const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); - -Deno.test("production Home preserves scoped navigation, permission boundaries, responsive layout, and keyboard access", async () => { +const ready = async (page: Page) => { + for (const id of ["home-attention", "home-active", "home-recent"]) { + await page.locator(`[data-feed-ready="${id}"]`).waitFor(); + } +}; +Deno.test("production Home dashboard isolates failures, shows authoritative records, and supports responsive keyboard navigation", async () => { const build = await new Deno.Command(Deno.execPath(), { args: ["task", "build"], cwd: join(root, "web/workspace"), @@ -30,19 +34,19 @@ Deno.test("production Home preserves scoped navigation, permission boundaries, r stderr: "null", }).spawn(); try { - let ready = false; + let started = false; for (let n = 0; n < 100; n++) { try { const response = await fetch(`${url}/health`); await response.body?.cancel(); if (response.ok) { - ready = true; + started = true; break; } - } catch { /* owned fixture is starting */ } + } catch { /* fixture is starting */ } await new Promise((resolve) => setTimeout(resolve, 50)); } - assert(ready, "fixture did not start"); + assert(started); const browser = await chromium.launch({ headless: true }); try { for (const colorScheme of ["light", "dark"] as const) { @@ -64,66 +68,69 @@ Deno.test("production Home preserves scoped navigation, permission boundaries, r page.on("request", (request) => requests.push(request.url())); for (const workspace of ["home-owner", "home-member", "home-long"]) { await page.goto(`${url}/w/${workspace}`); - const work = page.getByRole("navigation", { name: "Work", exact: true }); - await work.waitFor(); - assertEquals( - (await work.getByRole("link").allTextContents()).map((text) => text.trim()), - ["Tickets", "Objectives", "Merge Requests", "Memory", "Workers"], - ); - const settings = page.locator("main").getByRole("navigation", { - name: "Settings", - exact: true, - }); - assertEquals( - await settings.getByRole("link").count(), - workspace === "home-member" ? 2 : 6, - ); - assertEquals( - await settings.getByRole("link", { name: "Runtimes", exact: true }).count(), - workspace === "home-member" ? 0 : 1, - ); - assertEquals(await page.locator("main h1").allTextContents(), ["Work"]); + await ready(page); + assertEquals(await page.locator("main h1").allTextContents(), ["Activity"]); + assertEquals(await page.locator("main nav").count(), 0); + assertEquals(await page.locator("main [role=alert]").count(), 0); + assertEquals(await page.locator("[data-feed-ready=home-attention] a").count(), 3); + assertEquals(await page.locator("[data-feed-ready=home-active] a").count(), 2); + assertEquals(await page.locator("[data-feed-ready=home-recent] a").count(), 4); + await page.getByText("Blocked by T-99", { exact: true }).waitFor(); + await page.getByText("W-7", { exact: true }).waitFor(); const geometry = await page.locator(".workspace-home").evaluate((home) => { const main = home.closest("main")!; - const work = home.querySelector("section")!.getBoundingClientRect(); - const settings = home.querySelector(".home-settings")!.getBoundingClientRect(); + const sections = [...home.querySelectorAll("section")].map((section) => + section.getBoundingClientRect() + ); return { pageOverflow: document.documentElement.scrollWidth > innerWidth, mainOverflow: main.scrollWidth > main.clientWidth, - firstLinkBottom: home.querySelector("a")!.getBoundingClientRect().bottom, - sideBySide: settings.left > work.right, - stacked: settings.top >= work.bottom, - smallTargets: [...home.querySelectorAll("a")].filter((link) => - link.getBoundingClientRect().height < 44 - ).length, + firstLinkTop: home.querySelector("a")!.getBoundingClientRect().top, + sideBySide: sections[1].left > sections[0].right && + sections[2].left > sections[1].right, + stacked: sections[1].top >= sections[0].bottom && + sections[2].top >= sections[1].bottom, }; }); assertEquals(geometry.pageOverflow, false); assertEquals(geometry.mainOverflow, false); - assert(geometry.firstLinkBottom < 900); - assertEquals(geometry.smallTargets, 0); + assert(geometry.firstLinkTop < 900); assert(width === 1440 ? geometry.sideBySide : geometry.stacked); - const first = work.getByRole("link", { name: "Tickets", exact: true }); - await first.focus(); - const outline = await first.evaluate((link) => getComputedStyle(link).outlineStyle); - assertEquals(outline, "solid"); + const links = page.locator("main a"); + await links.first().focus(); + assertEquals( + await links.first().evaluate((link) => getComputedStyle(link).outlineStyle), + "solid", + ); await page.keyboard.press("Tab"); assertEquals( - await page.evaluate(() => document.activeElement?.textContent?.trim()), - "Objectives", + await links.nth(1).evaluate((link) => document.activeElement === link), + true, ); - for (const link of await page.locator("main a").all()) { - assert((await link.getAttribute("href"))!.startsWith(`/w/${workspace}/`)); + for (const link of await links.all()) { + assert( + new RegExp(`/w/${workspace}/(tickets|objectives|merge-requests)/[^/]+$`).test( + (await link.getAttribute("href"))!, + ), + ); } } - const memory = page.getByRole("navigation", { name: "Work", exact: true }).getByRole( - "link", - { name: "Memory", exact: true }, + const beforeRefresh = requests.filter((request) => + request.includes("/merge-requests?") + ).length; + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await page.getByRole("button", { name: "Refresh", exact: true }).waitFor(); + await ready(page); + assertEquals( + requests.filter((request) => request.includes("/merge-requests?")).length, + beforeRefresh + 1, ); - await memory.focus(); + const objective = page.locator("main a[href*='/objectives/O-12-']"); + await objective.focus(); await page.keyboard.press("Enter"); - await page.waitForURL(`**/w/home-long/memory`); - await page.getByRole("heading", { name: "Home navigation destination" }).waitFor(); + await page.waitForURL("**/objectives/O-12-*"); + await page.getByText("Home dashboard navigation destination.", { exact: true }) + .waitFor(); assertEquals( requests.some((request) => new URL(request).pathname.endsWith("/hosts")), false, @@ -134,6 +141,37 @@ Deno.test("production Home preserves scoped navigation, permission boundaries, r } } } + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); + try { + await page.goto(`${url}/w/home-empty`); + await ready(page); + assertEquals(await page.locator("main a").count(), 0); + await page.getByText("No open Merge Requests.", { exact: true }).waitFor(); + await page.goto(`${url}/w/home-error`); + await ready(page); + assertEquals( + await page.locator("[data-feed-ready=home-attention] [role=alert]").count(), + 1, + ); + assertEquals(await page.getByText("No open Merge Requests.", { exact: true }).count(), 0); + assertEquals(await page.locator("[data-feed-ready=home-active] a").count(), 2); + assertEquals(await page.locator("[data-feed-ready=home-recent] a").count(), 4); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route("**/merge-requests?*", async (route) => { + await held; + await route.continue(); + }); + await page.goto(`${url}/w/home-owner`); + await page.locator("[data-feed-ready=home-active]").waitFor(); + assertEquals(await page.getByText("Loading needs attention…", { exact: true }).count(), 1); + release(); + await ready(page); + } finally { + await page.close(); + } } finally { await browser.close(); } diff --git a/tools/web-ux/scenarios/workspace-home.json b/tools/web-ux/scenarios/workspace-home.json index 5e6db20cf..c29a168c7 100644 --- a/tools/web-ux/scenarios/workspace-home.json +++ b/tools/web-ux/scenarios/workspace-home.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "workspace-home", - "title": "Workspace Home task entry and permission boundaries", + "title": "Workspace Home operational dashboard", "baseUrl": "http://127.0.0.1:15281", "colorScheme": "light", "reducedMotion": "reduce", @@ -22,28 +22,61 @@ "id": "owner", "label": "Owner Home", "path": "/w/home-owner", - "goal": "Choose a daily resource without exposing infrastructure details; settings remain secondary.", - "dataState": "Workspace owner; empty repositories and workers; one Host available for before-change comparison.", - "ready": { "kind": "selector", "selector": "main a[href='/w/home-owner/tickets']" }, - "capturePoints": [{ "id": "initial", "label": "Home" }] + "goal": "Identify review work, blocked or assigned active tickets, and recent updates; open an actual record.", + "dataState": "Mixed review states, in-progress/queued tickets with a coder and dependency blocker, done/closed tickets and an Objective.", + "ready": { + "kind": "selector", + "selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])" + }, + "capturePoints": [{ "id": "initial", "label": "Dashboard" }] }, { "id": "non-owner", "label": "Non-owner Home", "path": "/w/home-member", - "goal": "Reach daily resources without seeing owner-only management links.", - "dataState": "All management permissions false; empty resource collections.", - "ready": { "kind": "selector", "selector": "main a[href='/w/home-member/tickets']" }, - "capturePoints": [{ "id": "initial", "label": "Home" }] + "goal": "Read the same work records without owner-only management controls.", + "dataState": "All management permissions false; same authorized read-only work projections as owner.", + "ready": { + "kind": "selector", + "selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])" + }, + "capturePoints": [{ "id": "initial", "label": "Dashboard" }] }, { "id": "long-name", - "label": "Long Workspace name", + "label": "Long names and selectors", "path": "/w/home-long", - "goal": "Keep resource navigation visible and avoid horizontal page overflow with long Workspace identity.", - "dataState": "Owner with a long Workspace name.", - "ready": { "kind": "selector", "selector": "main a[href='/w/home-long/tickets']" }, - "capturePoints": [{ "id": "initial", "label": "Home" }] + "goal": "Read long record titles and branch selectors without page-wide overflow.", + "dataState": "Owner with long Workspace name, Objective title, and Merge Request selectors.", + "ready": { + "kind": "selector", + "selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])" + }, + "capturePoints": [{ "id": "initial", "label": "Dashboard" }] + }, + { + "id": "empty", + "label": "Empty Workspace", + "path": "/w/home-empty", + "goal": "Distinguish empty work scopes without fabricated status totals.", + "dataState": "All dashboard resource queries successfully return empty collections.", + "ready": { + "kind": "selector", + "selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])" + }, + "capturePoints": [{ "id": "initial", "label": "Empty scopes" }] + }, + { + "id": "partial-error", + "label": "Reviews unavailable", + "path": "/w/home-error", + "goal": "Keep active work and recent updates usable when reviews fail; never claim all-clear.", + "dataState": "Intentional HTTP 503 from Merge Requests. Expected visible error and browser HTTP console diagnostic; other resource requests succeed.", + "ready": { + "kind": "selector", + "selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])" + }, + "capturePoints": [{ "id": "initial", "label": "Scoped review failure" }] } ], "processes": [{ diff --git a/web/workspace/README.md b/web/workspace/README.md index 686d4bbe2..1eb1e98d1 100644 --- a/web/workspace/README.md +++ b/web/workspace/README.md @@ -94,6 +94,7 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac - `src/routes/+layout.svelte`: root application shell and override contexts. - `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration. - `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration. +- `src/lib/workspace/home/`: bounded, read-only work dashboard feeds and their presentation. - `src/lib/workspace/header/`: Header frame, context, and overrides. - `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack. - `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers. diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 81a826029..cbb4c92f2 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -245,12 +245,10 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () ); assert( - workspacePage.includes("path: '/tickets'") && - workspacePage.includes("path: '/settings/runtimes'") && - workspacePage.includes("permissions.manage_runtimes === true") && - workspacePage.includes("path: '/workers'") && - workspacePage.includes("workspaceRoute(data.workspace.workspace_id, resource.path)"), - "Home should link to daily resources and permission-gated Settings without owning their inventories", + workspacePage.includes("DashboardSection") && + !workspacePage.includes(" + import BevelLine from '$lib/workspace/ui/BevelLine.svelte'; + import type { DashboardFeed } from './dashboard'; + + let { id, title, empty, result }: { id: string; title: string; empty: string; result: Promise } = $props(); + function timestamp(value?: string | null): string | null { + return value && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null; + } + function displayDate(value: string): string { + return new Date(value).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); + } + + +
+

{title}

+ {#await result} +

Loading {title.toLowerCase()}…

+ {:then feed} +
+ {#each feed.errors as error}{/each} + {#if feed.rows.length} + + {:else if !feed.errors.length} +

{empty}

+ {/if} + {#if feed.notice}

{feed.notice}

{/if} +
+ {:catch} + + {/await} +
+ + diff --git a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts new file mode 100644 index 000000000..bcb2cac38 --- /dev/null +++ b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts @@ -0,0 +1,194 @@ +// Deterministic read-only fixtures shared by Home component and browser tests. +export const fixturePage = (returned: number, more = false) => ({ + returned, + limit: 5, + sort: "priority", + has_more: more, + next_cursor: more ? "next" : null, + source_limit: null, + source_truncated: false, +}); +const updated = (hour: number) => + `2026-09-28T${String(hour).padStart(2, "0")}:00:00Z`; +export function fixtureTicket(key: string, state: string, hour = 10) { + return { + id: `internal-${key}`, + resource_key: key, + title: key === "T-101" + ? "Restore queued Worker submissions" + : `${state} ticket ${key}`, + state, + priority: "normal", + queued_at: null, + queued_by: null, + updated_at: updated(hour), + record_source: "fixture", + workspace_action_priority: state === "inprogress" || state === "queued" + ? "active_work" + : "background", + }; +} +export function fixtureDetail(key = "T-101") { + const summary = fixtureTicket(key, key === "T-101" ? "inprogress" : "queued"); + const { workspace_action_priority: _, ...ticket } = summary; + return { + ...ticket, + item_revision: "revision-1", + created_at: updated(1), + readiness: null, + body: "Implementation in progress.", + body_truncated: false, + resolution: null, + artifacts: [], + artifact_count: 0, + assignments: [], + assignment_diagnostics: [], + events: [], + event_count: 0, + event_page: fixturePage(0), + implementation_reports: [], + evidence: { + has_merge_request: false, + has_current_subject_ref: false, + has_review_request: false, + has_commit: false, + review_status: null, + approved_current_subject: false, + review_after_rescope: false, + unresolved_request_changes: false, + complete_for_integration: false, + missing: [], + }, + current_coder: key === "T-101" + ? { + assignment_id: "a-1", + runtime_id: "internal-runtime", + worker_id: "internal-worker", + worker_resource_key: "W-7", + } + : null, + action_eligibility: { + blockers: [], + can_assign_orchestrator: false, + can_queue: false, + can_start_manual_coder: false, + can_unassign_orchestrator: false, + queue_tickets: [], + }, + linked_objectives: [], + merge_request: null, + merge_requests: [], + targets: [], + risk_flags: [], + relations: { + outgoing: [], + incoming: [], + notices: [], + blockers: key === "T-101" + ? [{ + blocking_resource_key: "T-99", + blocking_state: "ready", + blocking_ticket: "internal-T-99", + note: null, + reason_kind: "dependency", + relation_kind: "depends_on", + }] + : [], + }, + }; +} +export function dashboardFixture( + path: string, + search: URLSearchParams, + workspaceId = "home-owner", +): unknown | null { + const empty = workspaceId === "home-empty"; + const long = workspaceId === "home-long"; + if (path === "/tickets") { + const state = search.get("states")!; + const items = empty + ? [] + : state === "inprogress,queued" + ? [fixtureTicket("T-101", "inprogress"), fixtureTicket("T-102", "queued")] + : state === "done" + ? [fixtureTicket("T-103", "done", 12), fixtureTicket("T-104", "done", 7)] + : [fixtureTicket("T-105", "closed", 14)]; + if (long && items[0]) { + items[0].title = "長いTicket名 — ".repeat(8) + + "preserve-the-full-title-without-horizontal-overflow"; + } + return { + workspace_id: workspaceId, + limit: 5, + items, + page: fixturePage(items.length, !empty), + invalid_records: [], + record_authority: "fixture", + }; + } + if (/^\/tickets\/T-10[12]/.test(path)) { + return fixtureDetail(path.includes("T-101") ? "T-101" : "T-102"); + } + const objective = { + id: "internal-objective", + resource_key: "O-12", + title: long ? "Objective ".repeat(18) : "Improve daily Workspace operation", + state: "active", + created_at: updated(1), + updated_at: updated(13), + summary: "", + linked_tickets: [], + record_source: "fixture", + }; + if (path === "/objectives") { + return { + workspace_id: workspaceId, + limit: 5, + items: empty ? [] : [objective], + invalid_records: [], + record_authority: "fixture", + }; + } + if (path.startsWith("/objectives/O-12")) { + const { summary: _, ...detail } = objective; + return { + ...detail, + body: "# Objective detail\n\nHome dashboard navigation destination.", + body_truncated: false, + revision: "revision-1", + linked_ticket_summaries: [], + resources: [], + events: [], + event_page: fixturePage(0), + }; + } + if (path === "/merge-requests") { + return { + items: empty + ? [] + : ["request_changes", "pending", "approved"].map((status, index) => ({ + summary: { + merge_request_id: `internal-merge-${index}`, + repository_key: "main", + state: "open", + selector_from: long + ? "feature/" + "long-branch-".repeat(15) + : ["fix/queue", "feat/home", "fix/sidebar"][index], + selector_to: "develop", + review_status: status, + updated_at: updated(15 - index), + review_excerpt: null, + review_requested_at: updated(10), + review_submitted_at: null, + current_subject_ref: "abc123", + review_subject_ref: "abc123", + }, + thread_event_count: 1, + ticket_ids: [], + ref_diagnostics: [], + })), + next_cursor: empty ? null : "older-reviews", + }; + } + return null; +} diff --git a/web/workspace/src/lib/workspace/home/dashboard.ts b/web/workspace/src/lib/workspace/home/dashboard.ts new file mode 100644 index 000000000..e831e6c3b --- /dev/null +++ b/web/workspace/src/lib/workspace/home/dashboard.ts @@ -0,0 +1,263 @@ +import type { TicketListItemSummary } from "$lib/generated/ticket-api"; +import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import { + parseMergeRequestListResponse, + parseObjectiveListResponse, + parseTicketDetail, + parseTicketListResponse, + TICKET_BROWSER_API_LOAD_POLICY, +} from "$lib/workspace/api/ticket-browser"; +import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests"; +import { objectiveHref, ticketHref } from "$lib/workspace/resource-links"; + +export const HOME_LIMIT = 5; +export type DashboardRow = { + key: string; + kind: "Ticket" | "Objective" | "Merge Request"; + reference: string; + title: string; + href: string; + status: string; + detail?: string; + workerKey?: string; + attention?: boolean; + updatedAt?: string | null; +}; +export type DashboardFeed = { + rows: DashboardRow[]; + errors: string[]; + notice?: string; +}; +export type Dashboard = { + reviews: Promise; + active: Promise; + recent: Promise; +}; + +const states: Record = { + inprogress: "In progress", + queued: "Queued", + done: "Done", + closed: "Closed", + active: "Active", + completed: "Completed", + paused: "Paused", +}; +const reviewStates: Record = { + none: "Not reviewed", + pending: "Review pending", + approved: "Approved", + request_changes: "Changes requested", + unresolved_changes: "Unresolved changes", +}; +function ticketRow( + workspaceId: string, + ticket: TicketListItemSummary, +): DashboardRow { + return { + key: `ticket:${ticket.resource_key}`, + kind: "Ticket", + reference: ticket.resource_key, + title: ticket.title, + href: ticketHref(workspaceId, ticket), + status: states[ticket.state] ?? ticket.state, + updatedAt: ticket.updated_at, + }; +} +function failure(label: string, error: string | null): string { + const status = error?.match(/HTTP \d{3}/)?.[0]; + return `${label} unavailable.${ + status ? ` ${status}.` : "" + } Refresh to retry.`; +} +function dateValue(value?: string | null): number { + const timestamp = value ? Date.parse(value) : NaN; + return Number.isFinite(timestamp) ? timestamp : -Infinity; +} +export function recentRows(rows: DashboardRow[]): DashboardRow[] { + const latest = new Map(); + for (const row of rows) { + const previous = latest.get(row.key); + if ( + !previous || dateValue(row.updatedAt) >= dateValue(previous.updatedAt) + ) latest.set(row.key, row); + } + return [...latest.values()] + .sort((a, b) => + dateValue(b.updatedAt) - dateValue(a.updatedAt) || + a.key.localeCompare(b.key) + ) + .slice(0, HOME_LIMIT); +} + +// These are bounded, read-only browser endpoints. QueryTicket/QueryObjective are +// tool-only capabilities and must not be approximated by guessed HTTP routes. +export function loadDashboard( + fetchFn: typeof fetch, + workspaceId: string, +): Dashboard { + function get(path: string, parse: (value: unknown) => T) { + return loadJson( + fetchFn, + workspaceApiPath(workspaceId, path), + { signal: AbortSignal.timeout(10_000) }, + parse, + TICKET_BROWSER_API_LOAD_POLICY, + ); + } + const tickets = (state: string) => + get( + `/tickets?${new URLSearchParams({ + states: state, + limit: String(HOME_LIMIT), + })}`, + parseTicketListResponse, + ); + const activeResult = tickets("inprogress,queued"); + const doneResult = tickets("done"); + const closedResult = tickets("closed"); + const objectivesResult = get( + `/objectives?limit=${HOME_LIMIT}`, + parseObjectiveListResponse, + ); + const reviewsResult = get( + `/merge-requests?state=open&limit=${HOME_LIMIT}`, + parseMergeRequestListResponse, + ); + + return { + reviews: reviewsResult.then((result): DashboardFeed => { + if (!result.data) { + return { rows: [], errors: [failure("Merge Requests", result.error)] }; + } + return { + rows: result.data.items.slice(0, HOME_LIMIT).map(( + { summary, ref_diagnostics }, + ) => ({ + key: `merge:${summary.merge_request_id}`, + kind: "Merge Request", + reference: summary.repository_key, + title: `${ + summary.selector_from ?? "Source unavailable" + } → ${summary.selector_to}`, + href: mergeRequestPagePath(workspaceId, summary.merge_request_id), + status: reviewStates[summary.review_status] ?? summary.review_status, + attention: ["request_changes", "unresolved_changes"].includes( + summary.review_status, + ) || Boolean(ref_diagnostics?.length), + detail: ref_diagnostics?.length + ? "Source or target needs attention" + : undefined, + updatedAt: summary.updated_at, + })), + errors: [], + notice: result.data.next_cursor + ? `Showing ${ + Math.min(result.data.items.length, HOME_LIMIT) + } most recently updated open Merge Requests.` + : undefined, + }; + }), + active: activeResult.then(async (result): Promise => { + if (!result.data) { + return { rows: [], errors: [failure("Active tickets", result.error)] }; + } + const details = await Promise.all( + result.data.items.slice(0, HOME_LIMIT).map(async (ticket) => { + const detail = await get( + `/tickets/${encodeURIComponent(ticket.resource_key)}`, + parseTicketDetail, + ); + if (!detail.data) { + return { + row: { + ...ticketRow(workspaceId, ticket), + detail: "Assignment and blockers unavailable", + }, + error: failure(`${ticket.resource_key} details`, detail.error), + }; + } + const current = detail.data; + // A concurrent transition can remove a Ticket from this snapshot's scope. + if (!["inprogress", "queued"].includes(current.state)) { + return { changed: true }; + } + const blockers = current.relations.blockers; + const row = ticketRow(workspaceId, { ...ticket, ...current }); + const blockedKeys = blockers.map((blocker) => + blocker.blocking_resource_key + ).filter(Boolean); + return { + row: { + ...row, + status: blockers.length ? `${row.status} · Blocked` : row.status, + attention: blockers.length > 0, + detail: blockers.length + ? (blockedKeys.length === blockers.length + ? `Blocked by ${blockedKeys.join(", ")}` + : `${blockers.length} unresolved dependencies`) + : current.current_coder + ? undefined + : "No coder assigned", + workerKey: current.current_coder?.worker_resource_key ?? + undefined, + }, + }; + }), + ); + return { + rows: details.flatMap((item) => item.row ? [item.row] : []), + errors: [ + ...details.flatMap((item) => item.error ? [item.error] : []), + ...(result.data.invalid_records.length + ? ["Some Ticket records could not be read."] + : []), + ], + notice: details.some((item) => item.changed) + ? "Work changed while loading. Refresh for the latest list." + : result.data.page.has_more || result.data.page.source_truncated + ? `Showing ${ + Math.min(result.data.items.length, HOME_LIMIT) + } active tickets, with in-progress work first.` + : undefined, + }; + }), + recent: Promise.all([doneResult, closedResult, objectivesResult]).then(( + [done, closed, objectives], + ): DashboardFeed => ({ + rows: recentRows([ + ...(done.data?.items ?? []).map((ticket) => + ticketRow(workspaceId, ticket) + ), + ...(closed.data?.items ?? []).map((ticket) => + ticketRow(workspaceId, ticket) + ), + ...(objectives.data?.items ?? []).map((objective): DashboardRow => ({ + key: `objective:${objective.resource_key}`, + kind: "Objective", + reference: objective.resource_key, + title: objective.title, + href: objectiveHref(workspaceId, objective), + status: states[objective.state] ?? objective.state, + updatedAt: objective.updated_at, + })), + ]), + errors: [ + ...(!done.data ? [failure("Done tickets", done.error)] : []), + ...(!closed.data ? [failure("Closed tickets", closed.error)] : []), + ...(!objectives.data ? [failure("Objectives", objectives.error)] : []), + ...([done.data, closed.data, objectives.data].some((data) => + data?.invalid_records.length + ) + ? ["Some records could not be read."] + : []), + ], + notice: + [done.data, closed.data, objectives.data].some((data) => + data?.items.length + ) + ? "Up to 5 latest updates to done / closed tickets and Objectives." + : undefined, + })), + }; +} diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/+page.svelte index b3924765c..55c5f7125 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/+page.svelte @@ -1,126 +1,53 @@ - - {data.workspace?.display_name ?? 'Workspace'} · Yoi - +{data.workspace?.display_name ?? 'Workspace'} · Yoi
- {#if data.workspace} -
-
-

Work

- -
-
-

Settings

- -
-
- {:else if data.workspaceError} - - Choose a workspace - {:else} -

Loading workspace…

- {/if} +
+

Activity

+ +
+ {#if refreshError}{/if} +
+ + + +
diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/+page.ts index 182ddd239..5f1276ca4 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/+page.ts @@ -1,5 +1,10 @@ +import { loadDashboard } from "$lib/workspace/home/dashboard"; import type { PageLoad } from "./$types"; -// Home uses the Workspace identity and permissions already loaded by its layout. -// Infrastructure inventories belong to Settings and must not delay task navigation. -export const load: PageLoad = ({ params }) => ({ workspaceId: params.workspaceId }); +export const load: PageLoad = ({ fetch, params, depends }) => { + depends("workspace:home"); + return { + workspaceId: params.workspaceId, + dashboard: loadDashboard(fetch, params.workspaceId), + }; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts index 985de6aed..10dba201f 100644 --- a/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts @@ -1,175 +1,266 @@ // @vitest-environment happy-dom -import { cleanup, render, screen, within } from "@testing-library/svelte"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/svelte"; import { afterEach, expect, test, vi } from "vitest"; import type { ComponentProps } from "svelte"; +import { invalidate } from "$app/navigation"; import Home from "./+page.svelte"; import { load } from "./+page"; -import { parseWorkspaceResponse } from "$lib/workspace/api/workspace-model"; +import { + type Dashboard, + type DashboardFeed, + loadDashboard, + recentRows, +} from "$lib/workspace/home/dashboard"; +import { + dashboardFixture, + fixtureDetail, +} from "$lib/workspace/home/dashboard.test-fixtures"; +vi.mock("$app/navigation", () => ({ invalidate: vi.fn(async () => {}) })); type Data = ComponentProps["data"]; -function homeData(owner = true, id = "home-review"): Data { +function fixtureFetch(failPath?: string) { + return vi.fn(async (path: RequestInfo | URL, _init?: RequestInit) => { + const url = new URL(String(path), "https://example.test"); + const [, id, resource] = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/)!; + return resource === failPath + ? Response.json({}, { status: 503 }) + : Response.json( + dashboardFixture(resource, url.searchParams, decodeURIComponent(id)), + ); + }); +} +function data(id = "home-owner", fetchFn: typeof fetch = fixtureFetch()): Data { return { workspaceId: id, - workspace: parseWorkspaceResponse({ - workspace_id: id, - display_name: "Workspace Home Review", - record_authority: "private-authority", - schema_version: 1, - auth: { - Passkey: { - rp_id: "example.test", - origin: "https://example.test", - public_base_url: "https://example.test", - cookie_name: "fixture", - }, - }, - permissions: { - manage_repositories: owner, - manage_secrets: owner, - manage_runtimes: owner, - delete_workspace: owner, - }, - extension_points: { - store: "private-store", - event_stream: { status: "ready", note: "", diagnostics: [] }, - host_worker_bridge: { status: "ready", note: "", diagnostics: [] }, - companion_console: { status: "ready", note: "", diagnostics: [] }, - }, - }), - workspaceError: null, - repositories: null, - repositoriesError: null, - accessibleWorkspaces: [], - workspaceCatalogError: null, - } as Data; + workspace: { workspace_id: id, display_name: "Home review" }, + dashboard: loadDashboard(fetchFn, id), + } as unknown as Data; } -afterEach(cleanup); - -test("Home presents daily resources in product order, without internal metadata or repeated identity", () => { - const { container } = render(Home, { - props: { params: { workspaceId: "home-review" }, data: homeData() }, +function mount(value = data()) { + return render(Home, { + props: { params: { workspaceId: value.workspaceId }, data: value }, }); - const work = screen.getByRole("navigation", { name: "Work" }); - expect( - within(work).getAllByRole("link").map((link) => link.textContent?.trim()), - ).toEqual([ - "Tickets", - "Objectives", - "Merge Requests", - "Memory", - "Workers", - ]); - expect( - within(work).getAllByRole("link").map((link) => link.getAttribute("href")), - ).toEqual([ - "/w/home-review/tickets", - "/w/home-review/objectives", - "/w/home-review/merge-requests", - "/w/home-review/memory", - "/w/home-review/workers", - ]); - expect(container.querySelectorAll("h1")).toHaveLength(1); - expect(container.textContent).not.toMatch( - /private-authority|private-store|home-review|Workspace Home Review|Hosts|Record authority|API/, +} +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +test("Home shows actual reviews, active assignments/blockers, and recent records, not navigation tiles", async () => { + mount(); + expect(await screen.findByText("Changes requested")).toBeTruthy(); + expect(await screen.findByText("Blocked by T-99")).toBeTruthy(); + expect(screen.getByText("W-7")).toBeTruthy(); + expect(await screen.findByText("Improve daily Workspace operation")) + .toBeTruthy(); + expect(screen.getByText("Review pending")).toBeTruthy(); + expect(screen.getByText("Approved")).toBeTruthy(); + expect(screen.queryByRole("navigation")).toBeNull(); + expect(screen.queryByRole("heading", { name: "Settings" })).toBeNull(); + expect(screen.getAllByRole("button")).toHaveLength(1); + expect(document.body.textContent).not.toMatch( + /internal-worker|internal-merge|internal-runtime|Record authority/, ); - expect( - container.querySelectorAll( - "button, .card, .workspace-action-card, .state-pill", - ), - ).toHaveLength(0); -}); - -test("owner management destinations are separate from daily resources", () => { - render(Home, { - props: { params: { workspaceId: "home-review" }, data: homeData() }, - }); - const settings = screen.getByRole("navigation", { name: "Settings" }); - expect(within(settings).getAllByRole("link").map((link) => link.textContent)) - .toEqual([ - "Runtimes", - "Configuration Sources", - "Repositories", - "Repository Access", - "Profile Sources", - "Workspace Identity", - ]); -}); - -test("non-owner retains read destinations without owner-only controls", () => { - render(Home, { - props: { params: { workspaceId: "home-review" }, data: homeData(false) }, - }); - const settings = screen.getByRole("navigation", { name: "Settings" }); - expect(within(settings).getAllByRole("link").map((link) => link.textContent)) - .toEqual(["Configuration Sources", "Profile Sources"]); - expect(screen.getAllByRole("link")).toHaveLength(7); -}); - -test.each( - [ - ["manage_runtimes", "Runtimes"], - ["manage_repositories", "Repositories"], - ["manage_secrets", "Repository Access"], - ["delete_workspace", "Workspace Identity"], - ] as const, -)( - "%s alone exposes only its matching management destination", - (permission, label) => { - const data = homeData(false); - data.workspace!.permissions[permission] = true; - render(Home, { props: { params: { workspaceId: "home-review" }, data } }); - const settings = screen.getByRole("navigation", { name: "Settings" }); - expect(within(settings).getAllByRole("link")).toHaveLength(3); - expect(within(settings).getByRole("link", { name: label })).toBeTruthy(); - }, -); - -test("route reuse updates all destinations and permissions from the new Workspace", async () => { - const view = render(Home, { - props: { params: { workspaceId: "home-review" }, data: homeData() }, - }); - await view.rerender({ data: homeData(false, "space / 日本") }); - expect(screen.queryByRole("link", { name: "Runtimes" })).toBeNull(); for (const link of screen.getAllByRole("link")) { expect(link.getAttribute("href")).toMatch( - /^\/w\/space%20%2F%20%E6%97%A5%E6%9C%AC\//, + /^\/w\/home-owner\/(tickets|objectives|merge-requests)\/[^/]+$/, ); } }); -test("loading and failure do not invent resources or management permissions", async () => { - const data = { ...homeData(), workspace: null } as unknown as Data; - const view = render(Home, { - props: { params: { workspaceId: "home-review" }, data }, - }); - expect(screen.getByRole("status").textContent).toBe("Loading workspace…"); - expect(screen.queryAllByRole("link")).toHaveLength(0); - await view.rerender({ - data: { - ...data, - workspaceError: "Workspace access unavailable", - } as unknown as Data, - }); - expect(screen.getByRole("alert").textContent).toBe( - "Workspace access unavailable", +test("loader only uses bounded read-only supported endpoints and advertises explicit refresh dependency", async () => { + const fetch = fixtureFetch(); + const depends = vi.fn(); + const result = await load( + { + fetch, + params: { workspaceId: "home-owner" }, + depends, + } as unknown as Parameters[0], ); + await Promise.all(Object.values(result!.dashboard)); + expect(depends).toHaveBeenCalledWith("workspace:home"); + expect(fetch).toHaveBeenCalledTimes(7); + for (const [path, init] of fetch.mock.calls) { + const url = new URL(String(path), "https://example.test"); + expect(init?.method ?? "GET").toBe("GET"); + expect(init?.signal).toBeTruthy(); + expect(url.pathname).not.toMatch(/query|hosts|workers|settings/); + if (!url.pathname.includes("/tickets/T-")) { + expect(url.searchParams.get("limit")).toBe("5"); + } + } + expect(fetch.mock.calls.some(([url]) => String(url).includes("states=done"))) + .toBe(true); expect( - screen.getByRole("link", { name: "Choose a workspace" }).getAttribute( - "href", - ), - ).toBe("/"); - expect(screen.queryByRole("navigation")).toBeNull(); + fetch.mock.calls.some(([url]) => String(url).includes("states=closed")), + ).toBe(true); }); -test("Home does not fetch infrastructure inventories", async () => { - const fetch = vi.fn(); +test("recent updates merge independently limited sources in actual update order, not state priority", async () => { + const feed = await data().dashboard.recent; + expect(feed.errors).toEqual([]); + expect(feed.rows.map((row) => row.reference)).toEqual([ + "T-105", + "O-12", + "T-103", + "T-104", + ]); + const rows = Array.from( + { length: 10 }, + (_, index) => ({ + ...feed.rows[0], + key: String(index), + updatedAt: index === 9 ? null : `2026-01-01T0${index}:00:00Z`, + }), + ); expect( - await load( - { - params: { workspaceId: "home-review" }, - fetch, - } as unknown as Parameters[0], - ), - ).toEqual({ workspaceId: "home-review" }); - expect(fetch).not.toHaveBeenCalled(); + recentRows([...rows, { ...rows[8], updatedAt: rows[0].updatedAt }]).map(( + row, + ) => row.key), + ).toEqual([ + "8", + "7", + "6", + "5", + "4", + ]); +}); + +test("partial failure is not an empty/all-clear state and leaves sibling sections usable", async () => { + mount(data("home-owner", fixtureFetch("/merge-requests"))); + expect((await screen.findByRole("alert")).textContent).toContain( + "Merge Requests", + ); + expect(screen.queryByText("No open Merge Requests.")).toBeNull(); + expect(await screen.findByText("Blocked by T-99")).toBeTruthy(); + expect(await screen.findByText("Improve daily Workspace operation")) + .toBeTruthy(); +}); + +test("one recent source failure preserves the other records and does not assert an empty feed", async () => { + const value = data("home-owner", fixtureFetch("/objectives")); + mount(value); + expect((await value.dashboard.recent).rows.map((row) => row.reference)) + .toEqual(["T-105", "T-103", "T-104"]); + expect((await screen.findByRole("alert")).textContent).toContain( + "Objectives", + ); + expect(screen.queryByText("No done / closed tickets or Objectives yet.")) + .toBeNull(); +}); + +test("a failed Ticket detail preserves the summary without inventing no-assignee or unblocked state", async () => { + const feed = await data("home-owner", fixtureFetch("/tickets/T-101")) + .dashboard.active; + expect(feed.rows[0].detail).toBe("Assignment and blockers unavailable"); + expect(feed.rows[0].workerKey).toBeUndefined(); + expect(feed.rows[0].attention).toBeUndefined(); + expect(feed.errors[0]).toContain("T-101"); +}); + +test("a concurrent Ticket transition leaves the active scope with a refresh notice", async () => { + const fallback = fixtureFetch(); + const fetchFn: typeof fetch = (path, init) => + String(path).endsWith("/tickets/T-101") + ? Promise.resolve(Response.json({ ...fixtureDetail(), state: "done" })) + : fallback(path, init); + const feed = await data("home-owner", fetchFn).dashboard.active; + expect(feed.rows.map((row) => row.reference)).toEqual(["T-102"]); + expect(feed.notice).toContain("Work changed"); +}); + +test("empty resources have scoped empty states and no fabricated totals", async () => { + mount(data("home-empty")); + expect(await screen.findByText("No open Merge Requests.")).toBeTruthy(); + expect(await screen.findByText("No in-progress or queued tickets.")) + .toBeTruthy(); + expect(await screen.findByText("No done / closed tickets or Objectives yet.")) + .toBeTruthy(); + expect(screen.queryAllByRole("link")).toHaveLength(0); +}); + +test("slow review loading does not hide the other completed sections", async () => { + const value = data(); + value.dashboard.reviews = new Promise(() => {}); + mount(value); + expect( + within(screen.getByRole("region", { name: "Needs attention" })).getByRole( + "status", + ).textContent, + ).toContain("Loading needs attention"); + expect(await screen.findByText("Blocked by T-99")).toBeTruthy(); + expect(await screen.findByText("Improve daily Workspace operation")) + .toBeTruthy(); +}); + +test("route reuse drops pending old Workspace results and updates scoped links", async () => { + let finish!: (feed: DashboardFeed) => void; + const value = data(); + value.dashboard.reviews = new Promise((resolve) => { + finish = resolve; + }); + const view = mount(value); + await view.rerender({ data: data("space / 日本") }); + await screen.findByText("Changes requested"); + finish({ rows: [], errors: ["OLD WORKSPACE ERROR"] }); + await Promise.resolve(); + expect(screen.queryByText("OLD WORKSPACE ERROR")).toBeNull(); + for (const link of screen.getAllByRole("link")) { + expect(link.getAttribute("href")).toContain( + "/w/space%20%2F%20%E6%97%A5%E6%9C%AC/", + ); + } +}); + +test("malformed review responses are unavailable rather than empty", async () => { + const fallback = fixtureFetch(); + const fetchFn: typeof fetch = (path, init) => + String(path).includes("/merge-requests?") + ? Promise.resolve(Response.json({ items: "invalid" })) + : fallback(path, init); + const feed = await data("home-owner", fetchFn).dashboard.reviews; + expect(feed.rows).toEqual([]); + expect(feed.errors).toEqual([ + "Merge Requests unavailable. Refresh to retry.", + ]); +}); + +test("unknown review states are preserved and branch text never becomes HTML", async () => { + const fallback = fixtureFetch(); + const fetchFn: typeof fetch = async (path, init) => { + const response = await fallback(path, init); + if (!String(path).includes("/merge-requests?")) return response; + const body = await response.json(); + body.items[0].summary.review_status = "future_status"; + body.items[0].summary.selector_from = ''; + return Response.json(body); + }; + const view = mount(data("home-owner", fetchFn)); + expect(await screen.findByText("future_status")).toBeTruthy(); + expect(view.container.querySelector("img")).toBeNull(); + expect(await screen.findByText(/ { + const value = data(); + const feed = await value.dashboard.recent; + feed.rows[0].updatedAt = "invalid"; + value.dashboard.recent = Promise.resolve(feed); + mount(value); + expect(await screen.findByText("Update time unavailable")).toBeTruthy(); +}); + +test("Refresh invalidates only the Home data dependency", async () => { + const value = data(); + mount(value); + await Promise.all(Object.values(value.dashboard)); + await fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + expect(invalidate).toHaveBeenCalledWith("workspace:home"); });