merge: workspace sidebar navigation

This commit is contained in:
Keisuke Hirata 2026-06-22 02:00:22 +09:00
commit 613f412659
No known key found for this signature in database
6 changed files with 860 additions and 226 deletions

View File

@ -0,0 +1,157 @@
<script lang="ts">
import type { ObjectiveListResponse, ObjectiveSummary } from './types';
const MAX_VISIBLE_OBJECTIVES = 6;
let loading = $state(true);
let error = $state<string | null>(null);
let objectives = $state<ObjectiveSummary[]>([]);
let invalidRecordCount = $state(0);
$effect(() => {
const controller = new AbortController();
void loadObjectives(controller.signal);
return () => controller.abort();
});
async function loadObjectives(signal: AbortSignal) {
loading = true;
error = null;
try {
const response = await fetch('/api/objectives', { signal });
if (!response.ok) {
throw new Error(`objectives request failed (${response.status})`);
}
const payload = (await response.json()) as ObjectiveListResponse;
objectives = Array.isArray(payload.items) ? payload.items.slice(0, MAX_VISIBLE_OBJECTIVES) : [];
invalidRecordCount = Array.isArray(payload.invalid_records) ? payload.invalid_records.length : 0;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
error = err instanceof Error ? err.message : 'objectives request failed';
objectives = [];
invalidRecordCount = 0;
} finally {
if (!signal.aborted) {
loading = false;
}
}
}
</script>
<section class="nav-section" aria-labelledby="objectives-heading">
<div class="section-heading-row">
<h2 id="objectives-heading">objectives</h2>
{#if !loading && !error}
<span class="section-count">{objectives.length}</span>
{/if}
</div>
{#if loading}
<p class="section-state">Loading objectives…</p>
{:else if error}
<p class="section-state error">{error}</p>
{:else if objectives.length === 0}
<p class="section-state">No objectives found.</p>
{:else}
<ul class="nav-list" aria-label="Objectives">
{#each objectives as objective (objective.id)}
<li class="nav-item">
<span class="item-title">{objective.title}</span>
<span class="item-meta">{objective.state}</span>
</li>
{/each}
</ul>
{/if}
{#if invalidRecordCount > 0}
<p class="section-note">{invalidRecordCount} invalid objective record(s) hidden.</p>
{/if}
</section>
<style>
.nav-section {
display: grid;
gap: 10px;
}
.section-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 {
margin: 0;
color: #94a3b8;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.section-count {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 999px;
color: #cbd5e1;
font-size: 0.72rem;
line-height: 1;
padding: 4px 8px;
}
.nav-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.nav-item {
display: grid;
gap: 3px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px;
background: rgba(15, 23, 42, 0.64);
padding: 10px 12px;
min-width: 0;
}
.item-title,
.item-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-title {
color: #e2e8f0;
font-weight: 700;
}
.item-meta,
.section-state,
.section-note {
color: #94a3b8;
font-size: 0.78rem;
}
.section-state,
.section-note {
margin: 0;
line-height: 1.45;
}
.section-state {
border: 1px dashed rgba(148, 163, 184, 0.2);
border-radius: 14px;
padding: 10px 12px;
}
.section-state.error {
border-color: rgba(248, 113, 113, 0.36);
color: #fecaca;
}
</style>

View File

@ -0,0 +1,106 @@
<script lang="ts">
import type { WorkspaceResponse } from './types';
type Props = {
workspace: WorkspaceResponse | null;
};
let { workspace }: Props = $props();
</script>
<section class="nav-section" aria-labelledby="repositories-heading">
<div class="section-heading-row">
<h2 id="repositories-heading">repositories</h2>
<span class="section-count">1</span>
</div>
<ul class="nav-list" aria-label="Repositories">
<li class="nav-item active">
<span class="item-title">{workspace?.display_name ?? 'local workspace'}</span>
<span class="item-meta">local project records</span>
</li>
</ul>
<p class="section-note">
Repository API is not wired yet; this placeholder keeps the navigation seam
ready without adding repository authority.
</p>
</section>
<style>
.nav-section {
display: grid;
gap: 10px;
}
.section-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 {
margin: 0;
color: #94a3b8;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.section-count {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 999px;
color: #cbd5e1;
font-size: 0.72rem;
line-height: 1;
padding: 4px 8px;
}
.nav-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.nav-item {
display: grid;
gap: 3px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px;
background: rgba(15, 23, 42, 0.64);
padding: 10px 12px;
min-width: 0;
}
.nav-item.active {
border-color: rgba(56, 189, 248, 0.36);
background: rgba(14, 165, 233, 0.1);
}
.item-title,
.item-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-title {
color: #e2e8f0;
font-weight: 700;
}
.item-meta,
.section-note {
color: #94a3b8;
font-size: 0.78rem;
}
.section-note {
margin: 0;
line-height: 1.45;
}
</style>

View File

@ -0,0 +1,157 @@
<script lang="ts">
import type { ListResponse, Worker } from './types';
const MAX_VISIBLE_WORKERS = 6;
let loading = $state(true);
let error = $state<string | null>(null);
let workers = $state<Worker[]>([]);
let placeholder = $state<string | null>(null);
$effect(() => {
const controller = new AbortController();
void loadWorkers(controller.signal);
return () => controller.abort();
});
async function loadWorkers(signal: AbortSignal) {
loading = true;
error = null;
placeholder = null;
try {
const response = await fetch('/api/workers', { signal });
if (response.status === 404) {
workers = [];
placeholder = 'Worker API is not integrated in this build yet.';
return;
}
if (!response.ok) {
throw new Error(`workers request failed (${response.status})`);
}
const payload = (await response.json()) as ListResponse<Worker>;
workers = Array.isArray(payload.items) ? payload.items.slice(0, MAX_VISIBLE_WORKERS) : [];
if (workers.length === 0) {
placeholder = 'No workers reported by the current API.';
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
error = err instanceof Error ? err.message : 'workers request failed';
workers = [];
} finally {
if (!signal.aborted) {
loading = false;
}
}
}
</script>
<section class="nav-section" aria-labelledby="workers-heading">
<div class="section-heading-row">
<h2 id="workers-heading">workers</h2>
{#if !loading && !error && workers.length > 0}
<span class="section-count">{workers.length}</span>
{/if}
</div>
{#if loading}
<p class="section-state">Checking workers…</p>
{:else if error}
<p class="section-state error">{error}</p>
{:else if workers.length === 0}
<p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p>
{:else}
<ul class="nav-list" aria-label="Workers">
{#each workers as worker (worker.worker_id)}
<li class="nav-item">
<span class="item-title">{worker.label}</span>
<span class="item-meta">
{worker.state} · {worker.status}{worker.role ? ` · ${worker.role}` : ''}
</span>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.nav-section {
display: grid;
gap: 10px;
}
.section-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 {
margin: 0;
color: #94a3b8;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.section-count {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 999px;
color: #cbd5e1;
font-size: 0.72rem;
line-height: 1;
padding: 4px 8px;
}
.nav-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.nav-item {
display: grid;
gap: 3px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px;
background: rgba(15, 23, 42, 0.64);
padding: 10px 12px;
min-width: 0;
}
.item-title,
.item-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-title {
color: #e2e8f0;
font-weight: 700;
}
.item-meta,
.section-state {
color: #94a3b8;
font-size: 0.78rem;
}
.section-state {
margin: 0;
border: 1px dashed rgba(148, 163, 184, 0.2);
border-radius: 14px;
padding: 10px 12px;
line-height: 1.45;
}
.section-state.error {
border-color: rgba(248, 113, 113, 0.36);
color: #fecaca;
}
</style>

View File

@ -0,0 +1,124 @@
<script lang="ts">
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
import type { WorkspaceResponse } from './types';
type Props = {
workspace: WorkspaceResponse | null;
workspaceError?: string | null;
};
let { workspace, workspaceError = null }: Props = $props();
</script>
<aside class="workspace-sidebar" aria-label="Workspace navigation">
<header class="sidebar-header">
<div class="workspace-label">
<span class="eyebrow">workspace</span>
<h1>{workspace?.display_name ?? 'Yoi workspace'}</h1>
{#if workspaceError}
<p class="workspace-status error">Workspace summary unavailable.</p>
{:else if workspace}
<p class="workspace-status">{workspace.workspace_id}</p>
{:else}
<p class="workspace-status">Loading workspace…</p>
{/if}
</div>
<button
class="settings-button"
type="button"
aria-label="Workspace settings"
title="Workspace settings placeholder"
disabled
>
</button>
</header>
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {workspace} />
<ObjectivesNavSection />
<WorkersNavSection />
</nav>
</aside>
<style>
.workspace-sidebar {
align-self: stretch;
min-width: 0;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 26px;
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(15, 23, 42, 0.82)),
rgba(15, 23, 42, 0.88);
box-shadow: 0 24px 80px rgba(2, 6, 23, 0.28);
padding: 18px;
}
.sidebar-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 22px;
min-width: 0;
}
.workspace-label {
display: grid;
gap: 5px;
min-width: 0;
}
.eyebrow {
color: #38bdf8;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1,
.workspace-status {
overflow-wrap: anywhere;
}
h1 {
margin: 0;
color: #f8fafc;
font-size: 1.1rem;
line-height: 1.2;
}
.workspace-status {
margin: 0;
color: #94a3b8;
font-size: 0.78rem;
line-height: 1.35;
}
.workspace-status.error {
color: #fecaca;
}
.settings-button {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 12px;
background: rgba(15, 23, 42, 0.7);
color: #cbd5e1;
cursor: not-allowed;
}
.sidebar-sections {
display: grid;
gap: 24px;
min-width: 0;
}
</style>

View File

@ -0,0 +1,82 @@
export type ExtensionPoint = {
status: string;
note: string;
};
export type WorkspaceResponse = {
workspace_id: string;
display_name: string;
record_authority: string;
extension_points: {
event_stream: ExtensionPoint;
host_worker_bridge: ExtensionPoint;
};
};
export type Diagnostic = {
code: string;
severity: string;
message: string;
};
export type Host = {
host_id: string;
label: string;
kind: string;
status: string;
observed_at: string;
last_seen_at: string;
capabilities: {
local_pod_inspection: string;
workspace_root: string;
os: string;
arch: string;
max_workers: number;
};
diagnostics: Diagnostic[];
};
export type Worker = {
worker_id: string;
host_id: string;
label: string;
pod_name: string;
role?: string;
profile?: string;
workspace_root?: string;
state: string;
status: string;
last_seen_at?: string;
implementation: { kind: string; pod_name: string };
diagnostics: Diagnostic[];
};
export type ListResponse<T> = {
workspace_id: string;
limit: number;
items: T[];
source: string;
diagnostics: Diagnostic[];
};
export type ObjectiveSummary = {
id: string;
title: string;
state: string;
updated_at?: string | null;
linked_tickets?: string[];
record_source?: string;
};
export type InvalidProjectRecord = {
label: string;
reason: string;
};
export type ObjectiveListResponse = {
workspace_id: string;
limit: number;
items: ObjectiveSummary[];
invalid_records: InvalidProjectRecord[];
record_authority: string;
};

View File

@ -1,59 +1,6 @@
<script lang="ts"> <script lang="ts">
type WorkspaceResponse = { import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
workspace_id: string; import type { Diagnostic, Host, ListResponse, Worker, WorkspaceResponse } from '$lib/workspace-sidebar/types';
display_name: string;
record_authority: string;
extension_points: {
event_stream: { status: string; note: string };
host_worker_bridge: { status: string; note: string };
};
};
type Diagnostic = {
code: string;
severity: string;
message: string;
};
type Host = {
host_id: string;
label: string;
kind: string;
status: string;
observed_at: string;
last_seen_at: string;
capabilities: {
local_pod_inspection: string;
workspace_root: string;
os: string;
arch: string;
max_workers: number;
};
diagnostics: Diagnostic[];
};
type Worker = {
worker_id: string;
host_id: string;
label: string;
pod_name: string;
role?: string;
profile?: string;
workspace_root?: string;
state: string;
status: string;
last_seen_at?: string;
implementation: { kind: string; pod_name: string };
diagnostics: Diagnostic[];
};
type ListResponse<T> = {
workspace_id: string;
limit: number;
items: T[];
source: string;
diagnostics: Diagnostic[];
};
const endpoints = [ const endpoints = [
{ label: 'Workspace', path: '/api/workspace' }, { label: 'Workspace', path: '/api/workspace' },
@ -67,7 +14,9 @@
let workspace = $state<WorkspaceResponse | null>(null); let workspace = $state<WorkspaceResponse | null>(null);
let hosts = $state<ListResponse<Host> | null>(null); let hosts = $state<ListResponse<Host> | null>(null);
let workers = $state<ListResponse<Worker> | null>(null); let workers = $state<ListResponse<Worker> | null>(null);
let loadError = $state<string | null>(null); let workspaceError = $state<string | null>(null);
let hostsError = $state<string | null>(null);
let workersError = $state<string | null>(null);
async function getJson<T>(path: string): Promise<T> { async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path); const response = await fetch(path);
@ -78,18 +27,32 @@
} }
async function loadWorkspace() { async function loadWorkspace() {
workspaceError = null;
try { try {
const [workspaceResponse, hostResponse, workerResponse] = await Promise.all([ workspace = await getJson<WorkspaceResponse>('/api/workspace');
getJson<WorkspaceResponse>('/api/workspace'),
getJson<ListResponse<Host>>('/api/hosts'),
getJson<ListResponse<Worker>>('/api/workers')
]);
workspace = workspaceResponse;
hosts = hostResponse;
workers = workerResponse;
loadError = null;
} catch (error) { } catch (error) {
loadError = error instanceof Error ? error.message : String(error); workspaceError = error instanceof Error ? error.message : String(error);
workspace = null;
}
}
async function loadHosts() {
hostsError = null;
try {
hosts = await getJson<ListResponse<Host>>('/api/hosts');
} catch (error) {
hostsError = error instanceof Error ? error.message : String(error);
hosts = null;
}
}
async function loadWorkers() {
workersError = null;
try {
workers = await getJson<ListResponse<Worker>>('/api/workers');
} catch (error) {
workersError = error instanceof Error ? error.message : String(error);
workers = null;
} }
} }
@ -99,6 +62,8 @@
$effect(() => { $effect(() => {
void loadWorkspace(); void loadWorkspace();
void loadHosts();
void loadWorkers();
}); });
</script> </script>
@ -110,7 +75,10 @@
/> />
</svelte:head> </svelte:head>
<main class="shell"> <div class="workspace-layout">
<WorkspaceSidebar {workspace} {workspaceError} />
<main class="shell">
<section class="hero"> <section class="hero">
<p class="eyebrow">Local / single-workspace bootstrap</p> <p class="eyebrow">Local / single-workspace bootstrap</p>
<h1>Yoi Workspace Control Plane</h1> <h1>Yoi Workspace Control Plane</h1>
@ -143,8 +111,8 @@
<dd>{workspace.extension_points.host_worker_bridge.status}</dd> <dd>{workspace.extension_points.host_worker_bridge.status}</dd>
</div> </div>
</dl> </dl>
{:else if loadError} {:else if workspaceError}
<p class="error">{loadError}</p> <p class="error">{workspaceError}</p>
{:else} {:else}
<p>Waiting for <code>/api/workspace</code></p> <p>Waiting for <code>/api/workspace</code></p>
{/if} {/if}
@ -195,7 +163,7 @@
<dd>{host.kind}</dd> <dd>{host.kind}</dd>
</div> </div>
<div> <div>
<dt>Local Pod inspection</dt> <dt>Local inspection</dt>
<dd>{host.capabilities.local_pod_inspection}</dd> <dd>{host.capabilities.local_pod_inspection}</dd>
</div> </div>
<div> <div>
@ -207,8 +175,8 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{:else if loadError} {:else if hostsError}
<p class="error">{loadError}</p> <p class="error">{hostsError}</p>
{:else} {:else}
<p>Waiting for <code>/api/hosts</code></p> <p>Waiting for <code>/api/hosts</code></p>
{/if} {/if}
@ -243,15 +211,15 @@
<td><code>{worker.host_id}</code></td> <td><code>{worker.host_id}</code></td>
<td>{worker.state} · {worker.status}</td> <td>{worker.state} · {worker.status}</td>
<td>{worker.workspace_root ?? 'unknown'}</td> <td>{worker.workspace_root ?? 'unknown'}</td>
<td>{worker.implementation.kind}: {worker.implementation.pod_name}</td> <td>{worker.implementation.kind}</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div> </div>
{/if} {/if}
{:else if loadError} {:else if workersError}
<p class="error">{loadError}</p> <p class="error">{workersError}</p>
{:else} {:else}
<p>Waiting for <code>/api/workers</code></p> <p>Waiting for <code>/api/workers</code></p>
{/if} {/if}
@ -275,9 +243,14 @@
</section> </section>
{/if} {/if}
{/if} {/if}
</main> </main>
</div>
<style> <style>
:global(*) {
box-sizing: border-box;
}
:global(body) { :global(body) {
margin: 0; margin: 0;
background: #0f172a; background: #0f172a;
@ -286,14 +259,28 @@
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
} }
.shell { .workspace-layout {
width: min(1120px, calc(100vw - 32px)); display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: 24px;
width: min(1240px, calc(100vw - 32px));
margin: 0 auto; margin: 0 auto;
padding: 48px 0; padding: 32px 0;
min-width: 0;
}
.shell {
display: grid;
gap: 24px;
min-width: 0;
} }
.hero { .hero {
margin-bottom: 24px; min-width: 0;
}
.hero p {
max-width: 68ch;
} }
.eyebrow { .eyebrow {
@ -307,12 +294,19 @@
margin: 0 0 16px; margin: 0 0 16px;
font-size: clamp(2.5rem, 8vw, 5rem); font-size: clamp(2.5rem, 8vw, 5rem);
line-height: 0.95; line-height: 0.95;
overflow-wrap: anywhere;
} }
h2 { h2 {
margin-top: 0; margin-top: 0;
} }
p,
li,
dd {
overflow-wrap: anywhere;
}
code { code {
color: #bae6fd; color: #bae6fd;
} }
@ -320,12 +314,12 @@
.grid { .grid {
display: grid; display: grid;
gap: 16px; gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
margin-top: 16px; min-width: 0;
} }
.runtime { .runtime {
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
} }
.card { .card {
@ -334,6 +328,7 @@
background: rgba(15, 23, 42, 0.75); background: rgba(15, 23, 42, 0.75);
padding: 24px; padding: 24px;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35); box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35);
min-width: 0;
} }
.stack { .stack {
@ -421,4 +416,17 @@
.error { .error {
color: #fca5a5; color: #fca5a5;
} }
@media (max-width: 760px) {
.workspace-layout {
grid-template-columns: 1fr;
width: min(100vw - 24px, 620px);
gap: 18px;
padding: 18px 0;
}
.card {
padding: 18px;
}
}
</style> </style>