fix: rebuild workspace home around daily navigation

This commit is contained in:
2026-10-01 13:52:16 +09:00
parent b9aaff4052
commit 696e6a51e7
10 changed files with 663 additions and 157 deletions
@@ -245,15 +245,12 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
);
assert(
workspacePage.includes("ticketsHref") &&
workspacePage.includes("runtimeSettingsHref") &&
workspacePage.includes("workersHref") &&
workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") &&
workspacePage.includes(
"workspaceRoute(workspaceId, '/settings/runtimes')",
) &&
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
"top workspace page should link to Tickets, Runtimes under Settings, and the Workers page",
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",
);
assert(
!workspacePage.includes("workerConsoleHref") &&
@@ -58,39 +58,6 @@
margin-top: var(--space-1);
}
.workspace-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.workspace-action-card {
display: grid;
gap: var(--space-1);
padding: var(--space-4);
border: 1px solid var(--line);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
}
.workspace-action-card:hover,
.workspace-action-card:focus-visible {
border-color: var(--accent);
}
.workspace-action-card span,
.workspace-action-card small {
color: var(--text-muted);
}
.workspace-action-card span {
font-size: var(--font-size-compact);
}
.workspace-action-card strong {
color: var(--text-strong);
}
@media (max-width: 720px) {
.workspace-actions {
grid-template-columns: 1fr;
}
}
.runtime-card {
padding: 0;
}
@@ -1,103 +1,126 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
let ticketsHref = $derived(workspaceRoute(workspaceId, '/tickets'));
let runtimeSettingsHref = $derived(workspaceRoute(workspaceId, '/settings/runtimes'));
let workersHref = $derived(workspaceRoute(workspaceId, '/workers'));
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));
</script>
<svelte:head>
<title>Yoi Workspace Control Plane</title>
<meta name="description" content="Local single-workspace Yoi control plane bootstrap" />
<title>{data.workspace?.display_name ?? 'Workspace'} · Yoi</title>
</svelte:head>
<section class="card">
<h2>Workspace</h2>
<div class="workspace-home">
{#if data.workspace}
<dl>
<div>
<dt>ID</dt>
<dd>{data.workspace.workspace_id}</dd>
</div>
<div>
<dt>Name</dt>
<dd>{data.workspace.display_name}</dd>
</div>
<div>
<dt>Record authority</dt>
<dd>{data.workspace.record_authority}</dd>
</div>
<div>
<dt>Host / Worker bridge</dt>
<dd>{data.workspace.extension_points.host_worker_bridge.status}</dd>
</div>
</dl>
<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="error">{data.workspaceError}</p>
<p class="home-error" role="alert">{data.workspaceError}</p>
<a class="recovery-link" href="/">Choose a workspace</a>
{:else}
<p>Waiting for <code>/api/workspace</code>…</p>
<p class="home-loading" role="status">Loading workspace…</p>
{/if}
</section>
</div>
<section class="workspace-actions" aria-label="Workspace sections">
<a class="workspace-action-card" href={ticketsHref}>
<span>Tickets</span>
<strong>Browse workspace tickets</strong>
<small>Read typed Ticket records</small>
</a>
<a class="workspace-action-card" href={runtimeSettingsHref}>
<span>Runtimes</span>
<strong>Open admin Runtimes</strong>
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
</a>
<a class="workspace-action-card" href={workersHref}>
<span>Workers</span>
<strong>Open worker list</strong>
<small>Inspect status and attach to consoles</small>
</a>
</section>
<section class="card">
<h2>Hosts</h2>
{#if data.hosts}
{#if data.hosts.items.length === 0}
<p>No local Hosts are visible.</p>
{:else}
<div class="stack">
{#each data.hosts.items as host}
<article class="runtime-card">
<div class="runtime-heading">
<strong>{host.label}</strong>
<span class:warn={host.status !== 'available'}>{host.status}</span>
</div>
<dl>
<div>
<dt>ID</dt>
<dd><code>{host.host_id}</code></dd>
</div>
<div>
<dt>Kind</dt>
<dd>{host.kind}</dd>
</div>
<div>
<dt>Runtime</dt>
<dd><code>{host.runtime_id}</code></dd>
</div>
<div>
<dt>Platform</dt>
<dd>{host.os} / {host.arch}</dd>
</div>
</dl>
</article>
{/each}
</div>
{/if}
{:else if data.hostsError}
<p class="error">{data.hostsError}</p>
{:else}
<p>Waiting for <code>/api/hosts</code>…</p>
{/if}
</section>
<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); }
}
</style>
@@ -1,28 +1,5 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseHostListResponse,
type HostListResponse,
} from "$lib/workspace/api/workspace-model";
import type { PageLoad } from "./$types";
const HOST_API_LOAD_POLICY = {
diagnosticLabel: "Host API",
maxResponseBytes: 4 * 1024 * 1024,
} as const;
export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const hosts = await loadJson<HostListResponse>(
fetch,
apiPath("/hosts"),
undefined,
parseHostListResponse,
HOST_API_LOAD_POLICY,
);
return {
workspaceId: params.workspaceId,
hosts: hosts.data,
hostsError: hosts.error,
};
};
// 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 });
@@ -0,0 +1,175 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, within } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import Home from "./+page.svelte";
import { load } from "./+page";
import { parseWorkspaceResponse } from "$lib/workspace/api/workspace-model";
type Data = ComponentProps<typeof Home>["data"];
function homeData(owner = true, id = "home-review"): 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;
}
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() },
});
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/,
);
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\//,
);
}
});
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",
);
expect(
screen.getByRole("link", { name: "Choose a workspace" }).getAttribute(
"href",
),
).toBe("/");
expect(screen.queryByRole("navigation")).toBeNull();
});
test("Home does not fetch infrastructure inventories", async () => {
const fetch = vi.fn();
expect(
await load(
{
params: { workspaceId: "home-review" },
fetch,
} as unknown as Parameters<typeof load>[0],
),
).toEqual({ workspaceId: "home-review" });
expect(fetch).not.toHaveBeenCalled();
});