fix: replace home navigation with work dashboard
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 <port>
|
||||
const names: Record<string, string> = {
|
||||
"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,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/// <reference lib="dom" />
|
||||
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<void>((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();
|
||||
}
|
||||
|
||||
@@ -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": [{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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("<nav") &&
|
||||
!workspacePage.includes("/settings/runtimes"),
|
||||
"Home should show work records without duplicating Sidebar navigation",
|
||||
);
|
||||
assert(
|
||||
!workspacePage.includes("workerConsoleHref") &&
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
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<DashboardFeed> } = $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' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section aria-labelledby={id} class="dashboard-section">
|
||||
<h2 {id}>{title}</h2>
|
||||
{#await result}
|
||||
<p class="feed-state" role="status">Loading {title.toLowerCase()}…</p>
|
||||
{:then feed}
|
||||
<div data-feed-ready={id}>
|
||||
{#each feed.errors as error}<p class="feed-error" role="alert">{error}</p>{/each}
|
||||
{#if feed.rows.length}
|
||||
<ul>
|
||||
{#each feed.rows as row, index (row.key)}
|
||||
{@const date = timestamp(row.updatedAt)}
|
||||
<li>
|
||||
<a href={row.href} class:attention={row.attention}>
|
||||
<div class="row-meta"><span>{row.kind}</span><code>{row.reference}</code></div>
|
||||
<strong class:machine-title={row.kind === 'Merge Request'}>{row.title}</strong>
|
||||
<span class="row-status">{row.status}</span>
|
||||
{#if row.workerKey}<span class="row-detail">Coder <code>{row.workerKey}</code></span>{/if}
|
||||
{#if row.detail}<span class="row-detail">{row.detail}</span>{/if}
|
||||
{#if date}<time datetime={date}>Updated {displayDate(date)}</time>{:else}<span class="row-detail">Update time unavailable</span>{/if}
|
||||
</a>
|
||||
{#if index < feed.rows.length - 1}<BevelLine decorative />{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if !feed.errors.length}
|
||||
<p class="feed-state">{empty}</p>
|
||||
{/if}
|
||||
{#if feed.notice}<p class="feed-notice">{feed.notice}</p>{/if}
|
||||
</div>
|
||||
{:catch}
|
||||
<p class="feed-error" role="alert">Could not load {title.toLowerCase()}. Refresh to try again.</p>
|
||||
{/await}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section { min-width: 0; }
|
||||
h2 { margin: 0 0 var(--space-2); font-size: var(--font-size-body); line-height: var(--line-height-body); color: var(--text-strong); }
|
||||
ul { list-style: none; margin: 0; padding: 0; }
|
||||
a { display: grid; gap: var(--space-1); min-width: 0; padding-block: var(--space-3); text-decoration: none; color: var(--text); overflow-wrap: anywhere; }
|
||||
a:hover, a:focus-visible { background: var(--interactive-hover); }
|
||||
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
a:active { background: var(--interactive-selected); }
|
||||
strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; color: var(--text-strong); font-size: var(--font-size-body); line-height: var(--line-height-body); font-weight: 600; }
|
||||
.machine-title { font-family: var(--font-mono); }
|
||||
.row-meta { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.row-meta, .row-detail, time, .row-status, .feed-notice { font-size: var(--font-size-compact); line-height: var(--line-height-compact); }
|
||||
.row-meta, .row-detail, time, .feed-state, .feed-notice { color: var(--text-muted); }
|
||||
code { font-family: var(--font-mono); }
|
||||
.row-status { font-weight: 600; }
|
||||
.attention .row-status { color: var(--warning); }
|
||||
.feed-state { margin: var(--space-3) 0; }
|
||||
.feed-error { margin: var(--space-3) 0; color: var(--danger); overflow-wrap: anywhere; }
|
||||
.feed-notice { margin: var(--space-3) 0 0; }
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<DashboardFeed>;
|
||||
active: Promise<DashboardFeed>;
|
||||
recent: Promise<DashboardFeed>;
|
||||
};
|
||||
|
||||
const states: Record<string, string> = {
|
||||
inprogress: "In progress",
|
||||
queued: "Queued",
|
||||
done: "Done",
|
||||
closed: "Closed",
|
||||
active: "Active",
|
||||
completed: "Completed",
|
||||
paused: "Paused",
|
||||
};
|
||||
const reviewStates: Record<string, string> = {
|
||||
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<string, DashboardRow>();
|
||||
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<T>(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<DashboardFeed> => {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1,126 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import DashboardSection from '$lib/workspace/home/DashboardSection.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const resources = [
|
||||
{ label: 'Tickets', path: '/tickets' },
|
||||
{ label: 'Objectives', path: '/objectives' },
|
||||
{ label: 'Merge Requests', path: '/merge-requests' },
|
||||
{ label: 'Memory', path: '/memory' },
|
||||
{ label: 'Workers', path: '/workers' },
|
||||
];
|
||||
const settings = $derived([
|
||||
{ label: 'Runtimes', path: '/settings/runtimes', visible: data.workspace?.permissions.manage_runtimes === true },
|
||||
{ label: 'Configuration Sources', path: '/settings/configuration', visible: true },
|
||||
{ label: 'Repositories', path: '/settings/repositories', visible: data.workspace?.permissions.manage_repositories === true },
|
||||
{ label: 'Repository Access', path: '/settings/repository-access', visible: data.workspace?.permissions.manage_secrets === true },
|
||||
{ label: 'Profile Sources', path: '/settings/profiles', visible: true },
|
||||
{ label: 'Workspace Identity', path: '/settings', visible: data.workspace?.permissions.delete_workspace === true },
|
||||
].filter((item) => item.visible));
|
||||
let refreshing = $state(false);
|
||||
let refreshError = $state<string | null>(null);
|
||||
async function refresh() {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
refreshError = null;
|
||||
try {
|
||||
await invalidate('workspace:home');
|
||||
await Promise.all(Object.values(data.dashboard));
|
||||
} catch {
|
||||
refreshError = 'Could not refresh. Try again.';
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.workspace?.display_name ?? 'Workspace'} · Yoi</title>
|
||||
</svelte:head>
|
||||
<svelte:head><title>{data.workspace?.display_name ?? 'Workspace'} · Yoi</title></svelte:head>
|
||||
|
||||
<div class="workspace-home">
|
||||
{#if data.workspace}
|
||||
<div class="home-columns">
|
||||
<section aria-labelledby="home-work-heading">
|
||||
<h1 id="home-work-heading">Work</h1>
|
||||
<nav aria-labelledby="home-work-heading">
|
||||
<ul>
|
||||
{#each resources as resource, index (resource.path)}
|
||||
<li>
|
||||
<a class="resource-link" href={workspaceRoute(data.workspace.workspace_id, resource.path)}>
|
||||
<span>{resource.label}</span>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
|
||||
</a>
|
||||
{#if index < resources.length - 1}<BevelLine decorative />{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
</section>
|
||||
<section class="home-settings" aria-labelledby="home-settings-heading">
|
||||
<h2 id="home-settings-heading">Settings</h2>
|
||||
<nav aria-labelledby="home-settings-heading">
|
||||
<ul>
|
||||
{#each settings as item (item.path)}
|
||||
<li><a href={workspaceRoute(data.workspace.workspace_id, item.path)}>{item.label}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
</section>
|
||||
</div>
|
||||
{:else if data.workspaceError}
|
||||
<p class="home-error" role="alert">{data.workspaceError}</p>
|
||||
<a class="recovery-link" href="/">Choose a workspace</a>
|
||||
{:else}
|
||||
<p class="home-loading" role="status">Loading workspace…</p>
|
||||
{/if}
|
||||
<header class="home-header">
|
||||
<h1>Activity</h1>
|
||||
<button type="button" onclick={refresh} disabled={refreshing}>{refreshing ? 'Refreshing…' : 'Refresh'}</button>
|
||||
</header>
|
||||
{#if refreshError}<p class="home-error" role="alert">{refreshError}</p>{/if}
|
||||
<div class="home-columns">
|
||||
<DashboardSection id="home-attention" title="Needs attention" empty="No open Merge Requests." result={data.dashboard.reviews} />
|
||||
<DashboardSection id="home-active" title="In progress" empty="No in-progress or queued tickets." result={data.dashboard.active} />
|
||||
<DashboardSection id="home-recent" title="Recent updates" empty="No done / closed tickets or Objectives yet." result={data.dashboard.recent} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.workspace-home {
|
||||
container-type: inline-size;
|
||||
min-width: 0;
|
||||
}
|
||||
.home-columns {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
align-items: start;
|
||||
}
|
||||
section { min-width: 0; }
|
||||
h1 {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-title);
|
||||
line-height: var(--line-height-title);
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
ul { list-style: none; margin: 0; padding: 0; }
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
min-height: 44px;
|
||||
padding-block: var(--space-3);
|
||||
color: var(--text);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.resource-link { font-weight: 600; }
|
||||
a:hover, a:focus-visible { background: var(--interactive-hover); }
|
||||
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
a:active { background: var(--interactive-selected); }
|
||||
svg {
|
||||
flex: 0 0 auto;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.home-error, .home-loading { margin: 0; overflow-wrap: anywhere; }
|
||||
.home-error { color: var(--danger); }
|
||||
.home-loading { color: var(--text-muted); }
|
||||
.recovery-link { width: fit-content; text-decoration: underline; }
|
||||
@container (min-width: 48rem) {
|
||||
.home-columns { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
|
||||
.home-settings { padding-top: var(--space-3); }
|
||||
.workspace-home { container-type: inline-size; min-width: 0; }
|
||||
.home-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-5); }
|
||||
h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); color: var(--text-strong); }
|
||||
button { flex: 0 0 auto; padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); color: var(--text); background: transparent; cursor: pointer; }
|
||||
button:hover:not(:disabled), button:focus-visible { background: var(--interactive-hover); }
|
||||
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
button:active:not(:disabled) { background: var(--interactive-selected); }
|
||||
button:disabled { opacity: 0.6; cursor: wait; }
|
||||
.home-columns { display: grid; gap: var(--space-6); align-items: start; }
|
||||
.home-error { color: var(--danger); overflow-wrap: anywhere; }
|
||||
@container (min-width: 52rem) {
|
||||
.home-columns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<typeof Home>["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<typeof load>[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<typeof load>[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 = '<img src=x onerror="alert(1)">';
|
||||
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(/<img src=x/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("unavailable timestamps are not presented as current activity", async () => {
|
||||
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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user