feat: add workspace breadcrumbs

This commit is contained in:
2026-07-31 23:48:46 +09:00
parent 06a98fe30c
commit 48556e1a2c
15 changed files with 281 additions and 30 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts", "test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { getHeaderController } from './context';
let { content }: { content: Snippet<[]> } = $props();
const controller = getHeaderController();
$effect(() => {
controller.content = content;
return () => {
if (controller.content === content) controller.content = null;
};
});
</script>
@@ -0,0 +1,74 @@
<script lang="ts">
import { page } from '$app/state';
import { buildWorkspaceBreadcrumbs } from './breadcrumb-model';
let { workspaceId }: { workspaceId: string } = $props();
const workerName = $derived.by(() => {
const data = page.data as Record<string, unknown>;
const worker = data.worker as { display_name?: string | null; label?: string | null } | null | undefined;
return worker?.display_name ?? worker?.label ?? null;
});
const breadcrumbs = $derived(buildWorkspaceBreadcrumbs(page.url.pathname, workspaceId, { workerName }));
const workspaceRoot = $derived(`/w/${encodeURIComponent(workspaceId)}`);
</script>
<nav class="workspace-breadcrumbs" aria-label="Current workspace location">
<a class="workspace-breadcrumb-root" href={workspaceRoot} aria-label="Workspace home">/</a>
{#each breadcrumbs as breadcrumb, index (`${index}:${breadcrumb.label}`)}
{#if index > 0}<span class="workspace-breadcrumb-separator" aria-hidden="true">/</span>{/if}
{#if breadcrumb.href}
<a href={breadcrumb.href}>{breadcrumb.label}</a>
{:else}
<span class="workspace-breadcrumb-label" aria-current={index === breadcrumbs.length - 1 ? 'page' : undefined}>
{breadcrumb.label}
</span>
{/if}
{/each}
</nav>
<style>
.workspace-breadcrumbs {
display: flex;
min-width: 0;
align-items: center;
gap: 0.55rem;
color: var(--workspace-muted, #53606e);
font-family: var(--workspace-font-mono, monospace);
font-size: 0.84rem;
line-height: 1;
}
.workspace-breadcrumbs a,
.workspace-breadcrumb-label {
max-width: min(32vw, 28rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-breadcrumbs a {
color: inherit;
text-decoration: none;
}
.workspace-breadcrumbs a:hover {
color: var(--workspace-ink, #151b23);
text-decoration: underline;
text-underline-offset: 0.22rem;
}
.workspace-breadcrumb-root {
font-weight: 700;
}
.workspace-breadcrumb-separator {
color: color-mix(in srgb, currentColor 45%, transparent);
user-select: none;
}
.workspace-breadcrumbs span[aria-current='page'] {
color: var(--workspace-ink, #151b23);
font-weight: 600;
}
</style>
@@ -0,0 +1,50 @@
import { buildWorkspaceBreadcrumbs } from "./breadcrumb-model.ts";
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
};
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
Deno.test("Ticket detail breadcrumbs expose the Ticket list and current id", () => {
assertEquals(
buildWorkspaceBreadcrumbs("/w/workspace/tickets/TICKET-42", "workspace"),
[
{ label: "tickets", href: "/w/workspace/tickets" },
{ label: "TICKET-42" },
],
);
});
Deno.test("Worker console breadcrumbs use the logical Workers route and display name", () => {
assertEquals(
buildWorkspaceBreadcrumbs(
"/w/workspace/runtimes/runtime-a/workers/worker-7/console",
"workspace",
{ workerName: "Review Worker" },
),
[
{ label: "workers", href: "/w/workspace/workers" },
{ label: "Review Worker" },
],
);
});
Deno.test("Worker console breadcrumbs fall back to Worker id", () => {
assertEquals(
buildWorkspaceBreadcrumbs(
"/w/workspace/runtimes/runtime-a/workers/worker-7/console",
"workspace",
),
[
{ label: "workers", href: "/w/workspace/workers" },
{ label: "worker-7" },
],
);
});
@@ -0,0 +1,74 @@
export type WorkspaceBreadcrumb = {
label: string;
href?: string;
};
export type WorkspaceBreadcrumbContext = {
workerName?: string | null;
};
export function buildWorkspaceBreadcrumbs(
pathname: string,
workspaceId: string,
context: WorkspaceBreadcrumbContext = {},
): WorkspaceBreadcrumb[] {
const workspaceRoot = `/w/${encodeURIComponent(workspaceId)}`;
const prefix = `${workspaceRoot}/`;
if (pathname === workspaceRoot || pathname === `${workspaceRoot}/`) return [];
if (!pathname.startsWith(prefix)) return [];
const segments = pathname
.slice(prefix.length)
.split("/")
.filter(Boolean)
.map(decodeURIComponent);
if (
segments[0] === "runtimes" &&
segments[2] === "workers" &&
segments[3]
) {
return [
{ label: "workers", href: `${workspaceRoot}/workers` },
{ label: context.workerName?.trim() || segments[3] },
];
}
if (
segments[0] === "settings" && segments[1] === "profiles" &&
segments[2] === "trees"
) {
return [
{ label: "settings", href: `${workspaceRoot}/settings` },
{ label: "profiles", href: `${workspaceRoot}/settings/profiles` },
{ label: segments[3] || "trees" },
];
}
if (
segments[0] === "settings" &&
segments[1] === "runtimes" &&
segments[2] &&
segments[3] === "workdirs"
) {
return [
{ label: "settings", href: `${workspaceRoot}/settings` },
{ label: "runtimes", href: `${workspaceRoot}/settings/runtimes` },
{ label: segments[2] },
{ label: "workdirs" },
];
}
return segments.map((segment, index) => {
const isCurrent = index === segments.length - 1;
const href = isCurrent || (segments[0] === "repositories" && index === 0)
? undefined
: `${workspaceRoot}/${
segments.slice(0, index + 1).map(encodeURIComponent).join("/")
}`;
return {
label: segment,
href,
};
});
}
@@ -0,0 +1,17 @@
import { getContext, setContext } from "svelte";
import type { Snippet } from "svelte";
const HEADER_CONTEXT_KEY = Symbol("workspace-header");
export type HeaderContent = Snippet<[]> | null;
export type HeaderController = {
content: HeaderContent;
};
export function provideHeaderController(controller: HeaderController): void {
setContext(HEADER_CONTEXT_KEY, controller);
}
export function getHeaderController(): HeaderController {
return getContext<HeaderController>(HEADER_CONTEXT_KEY);
}
+13 -1
View File
@@ -2,6 +2,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { setContext } from 'svelte'; import { setContext } from 'svelte';
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte'; import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context';
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte'; import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte'; import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
import { SIDEBAR_CONTEXT, type SidebarSnippet } from '$lib/workspace/sidebar/context'; import { SIDEBAR_CONTEXT, type SidebarSnippet } from '$lib/workspace/sidebar/context';
@@ -10,7 +11,9 @@
let { children }: LayoutProps = $props(); let { children }: LayoutProps = $props();
let sidebar = $state<SidebarSnippet | null>(null); let sidebar = $state<SidebarSnippet | null>(null);
const headerController = $state<HeaderController>({ content: null });
provideHeaderController(headerController);
setContext(SIDEBAR_CONTEXT, { setContext(SIDEBAR_CONTEXT, {
setSidebar(snippet: SidebarSnippet) { setSidebar(snippet: SidebarSnippet) {
sidebar = snippet; sidebar = snippet;
@@ -32,6 +35,9 @@
{/if} {/if}
</SidebarFrame> </SidebarFrame>
<header class="workspace-topbar"> <header class="workspace-topbar">
<div class="workspace-topbar-location">
{#if headerController.content}{@render headerController.content()}{/if}
</div>
<nav class="workspace-topbar-actions" aria-label="Global navigation"> <nav class="workspace-topbar-actions" aria-label="Global navigation">
<a class="topbar-icon-button" href="/account" aria-label="Open Account" title="Account"> <a class="topbar-icon-button" href="/account" aria-label="Open Account" title="Account">
<svg class="topbar-icon" aria-hidden="true" viewBox="0 0 24 24"> <svg class="topbar-icon" aria-hidden="true" viewBox="0 0 24 24">
@@ -64,7 +70,8 @@
grid-row: 1; grid-row: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: space-between;
gap: var(--space-4);
min-width: 0; min-width: 0;
min-height: 3.25rem; min-height: 3.25rem;
padding: 0 var(--space-5); padding: 0 var(--space-5);
@@ -73,6 +80,11 @@
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
} }
.workspace-topbar-location {
min-width: 0;
overflow: hidden;
}
.workspace-topbar-actions { .workspace-topbar-actions {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte'; import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte'; import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
import '$lib/workspace/styles/workspace-pages.css'; import '$lib/workspace/styles/workspace-pages.css';
@@ -10,6 +12,10 @@
let { data, children }: LayoutProps = $props(); let { data, children }: LayoutProps = $props();
</script> </script>
{#snippet workspaceHeader()}
<WorkspaceBreadcrumbs workspaceId={page.params.workspaceId ?? data.workspace?.workspace_id ?? ''} />
{/snippet}
{#snippet workspaceSidebar()} {#snippet workspaceSidebar()}
<WorkspaceSidebar <WorkspaceSidebar
workspace={data.workspace ?? null} workspace={data.workspace ?? null}
@@ -20,6 +26,7 @@
/> />
{/snippet} {/snippet}
<HeaderOverride content={workspaceHeader} />
<SidebarOverride sidebar={workspaceSidebar} /> <SidebarOverride sidebar={workspaceSidebar} />
{@render children()} {@render children()}
@@ -42,7 +42,6 @@
<div class="objective-title-row detail"> <div class="objective-title-row detail">
<div> <div>
<h3>{data.objective.title}</h3> <h3>{data.objective.title}</h3>
<p><code>{data.objective.id}</code></p>
</div> </div>
<span class="state-pill">{data.objective.state}</span> <span class="state-pill">{data.objective.state}</span>
</div> </div>
@@ -15,7 +15,6 @@
<div class="repository-detail-heading"> <div class="repository-detail-heading">
<div> <div>
<h3>{data.repository.item.display_name}</h3> <h3>{data.repository.item.display_name}</h3>
<p><code>{data.repository.item.id}</code></p>
</div> </div>
<span class="status-pill" class:warn={data.repository.item.git?.status !== 'clean'}>{data.repository.item.git?.status ?? 'not observed'}</span> <span class="status-pill" class:warn={data.repository.item.git?.status !== 'clean'}>{data.repository.item.git?.status ?? 'not observed'}</span>
</div> </div>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { tick, untrack } from "svelte";
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte"; import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte"; import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
import { chatSubmit } from "$lib/workspace/console/chat-submit"; import { chatSubmit } from "$lib/workspace/console/chat-submit";
@@ -35,6 +35,8 @@
workspaceId: string; workspaceId: string;
runtimeId: string; runtimeId: string;
workerId: string; workerId: string;
worker: Worker | null;
workerError: string | null;
}; };
}; };
@@ -84,9 +86,11 @@
client: number; client: number;
}; };
let worker = $state<Worker | null>(null); let worker = $state<Worker | null>(untrack(() => data.worker));
let liveWorkerState = $state<string | null>(null); let liveWorkerState = $state<string | null>(
let workerError = $state<string | null>(null); untrack(() => data.worker?.state ?? null),
);
let workerError = $state<string | null>(untrack(() => data.workerError));
let draft = $state(""); let draft = $state("");
let completionEntries = $state<ComposerCompletionEntry[]>([]); let completionEntries = $state<ComposerCompletionEntry[]>([]);
let completionToken = $state<ComposerCompletionToken | null>(null); let completionToken = $state<ComposerCompletionToken | null>(null);
@@ -193,7 +197,7 @@
} }
async function loadConsoleData(target: ConsoleTarget) { async function loadConsoleData(target: ConsoleTarget) {
await loadWorker(target); if (!worker) await loadWorker(target);
} }
function advanceReloadToken(): number { function advanceReloadToken(): number {
@@ -1101,10 +1105,7 @@
</svelte:head> </svelte:head>
<div class="console-shell worker-console-shell"> <div class="console-shell worker-console-shell">
<section class="console-header card"> <section class="console-header card" aria-label="Worker controls">
<div>
<h2>{worker?.label ?? workerId}</h2>
</div>
<div class="console-header-actions"> <div class="console-header-actions">
<div <div
class="console-status-pill" class="console-status-pill"
@@ -1413,7 +1414,7 @@
.console-header { .console-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: flex-end;
gap: var(--space-4); gap: var(--space-4);
} }
@@ -1,11 +1,23 @@
export function load( import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
{ params }: { import type { Worker } from "$lib/workspace/sidebar/types";
params: { workspaceId: string; runtimeId: string; workerId: string }; import type { PageLoad } from "./$types";
},
) { export const load: PageLoad = async ({ fetch, params }) => {
const worker = await loadJson<Worker>(
fetch,
workspaceApiPath(
params.workspaceId,
`/runtimes/${encodeURIComponent(params.runtimeId)}/workers/${
encodeURIComponent(params.workerId)
}`,
),
);
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
runtimeId: params.runtimeId, runtimeId: params.runtimeId,
workerId: params.workerId, workerId: params.workerId,
worker: worker.data,
workerError: worker.error,
};
}; };
}
@@ -7,7 +7,7 @@
fetchProfileTreeFile, fetchProfileTreeFile,
writeProfileTreeFile, writeProfileTreeFile,
} from '$lib/workspace/settings/profile-api'; } from '$lib/workspace/settings/profile-api';
import { profileSettingsHref, virtualProfilePathForCreate } from '$lib/workspace/settings/profile-routes'; import { virtualProfilePathForCreate } from '$lib/workspace/settings/profile-routes';
import type { Diagnostic } from '$lib/workspace/settings/model'; import type { Diagnostic } from '$lib/workspace/settings/model';
import type { import type {
WorkspaceProfileSourceTreeFileResponse, WorkspaceProfileSourceTreeFileResponse,
@@ -122,10 +122,8 @@
<section class="card settings-section" aria-labelledby="profile-tree-title"> <section class="card settings-section" aria-labelledby="profile-tree-title">
<header class="settings-section-header"> <header class="settings-section-header">
<div> <div>
<p class="eyebrow">Profile source tree</p> <h2 id="profile-tree-title">Profile source tree</h2>
<h2 id="profile-tree-title">{sourceTreeId}</h2>
</div> </div>
<a href={profileSettingsHref(workspaceId)}>Back to profiles</a>
</header> </header>
{#if loading} {#if loading}
@@ -164,15 +164,10 @@
<svelte:head><title>{ticket.title} · Yoi</title></svelte:head> <svelte:head><title>{ticket.title} · Yoi</title></svelte:head>
<div class="workspace-page ticket-detail-page"> <div class="workspace-page ticket-detail-page">
<a class="workspace-back-link" href={`/w/${encodeURIComponent(data.workspaceId)}/tickets`}>
← Ticket board
</a>
<header class="ticket-detail-header"> <header class="ticket-detail-header">
<div> <div>
<div class="ticket-detail-kicker"> <div class="ticket-detail-kicker">
<span class="workspace-status-pill" data-status={ticket.state}>{ticket.state}</span> <span class="workspace-status-pill" data-status={ticket.state}>{ticket.state}</span>
<code>{ticket.id}</code>
</div> </div>
<h1>{ticket.title}</h1> <h1>{ticket.title}</h1>
<p>Updated {prettyDate(ticket.updated_at)}</p> <p>Updated {prettyDate(ticket.updated_at)}</p>
@@ -272,7 +272,6 @@
<h1 id="new-worker-heading">New Worker</h1> <h1 id="new-worker-heading">New Worker</h1>
<p>Create a Worker on a selected Runtime. Workdir-less conversation Workers are only available on embedded Runtime.</p> <p>Create a Worker on a selected Runtime. Workdir-less conversation Workers are only available on embedded Runtime.</p>
</div> </div>
<a class="secondary-link" href={`/w/${workspaceId}`}>Back to workspace</a>
</header> </header>
{#if ticketContext} {#if ticketContext}