Merge branch 'work/companion' into hare/develop

This commit is contained in:
2026-08-31 15:51:36 +09:00
22 changed files with 579 additions and 311 deletions
@@ -147,9 +147,21 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
assert(
!sidebar.includes("CompanionNavSection") &&
sidebar.includes("TicketsNavSection") &&
sidebar.includes("MemoryNavSection") &&
sidebar.includes("WorkersNavSection"),
"standalone Companion/Console navigation should not remain canonical and Tickets should be primary workspace navigation",
sidebar.includes("MergeRequestsNavSection") &&
sidebar.lastIndexOf("MergeRequestsNavSection") <
sidebar.lastIndexOf("MemoryNavSection") &&
sidebar.includes("WorkersNavSection") &&
sidebarCss.includes("gap: var(--space-1)") &&
sidebarCss.includes(".sidebar-nav-section--category > .sidebar-link") &&
sidebarCss.includes("padding-block: var(--space-1)") &&
sidebarCss.includes("margin-left: var(--space-3)") &&
sidebarCss.includes("--sidebar-item-hover: oklch(24% 0 0)") &&
sidebarCss.includes("--sidebar-item-active: oklch(32% 0 0)") &&
sidebarCss.includes("background: var(--sidebar-item-hover)") &&
sidebarCss.includes("background: var(--sidebar-item-active)") &&
!sidebarCss.includes("background: var(--interactive-selected)") &&
!sidebarCss.includes("margin-inline: calc(-1"),
"workspace navigation should place Merge Requests before an indented compact Memory category",
);
});
@@ -160,6 +172,9 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
const objectivesNav = await Deno.readTextFile(
new URL("../sidebar/ObjectivesNavSection.svelte", import.meta.url),
);
const mergeRequestsNav = await Deno.readTextFile(
new URL("../sidebar/MergeRequestsNavSection.svelte", import.meta.url),
);
const ticketsLoad = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/tickets/+page.ts",
@@ -205,15 +220,22 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
assert(
ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") &&
ticketsNav.includes('class="primary-nav-link"') &&
ticketsNav.includes("sidebar-nav-section--resource") &&
ticketsNav.includes('class="sidebar-link"') &&
ticketsNav.includes(">Tickets</a>") &&
!ticketsNav.includes("Open Tickets") &&
!ticketsNav.includes("workspace tickets") &&
objectivesNav.includes('class="primary-nav-link"') &&
objectivesNav.includes("sidebar-nav-section--resource") &&
objectivesNav.includes('class="sidebar-link"') &&
objectivesNav.includes(">Objectives</a>") &&
!objectivesNav.includes("Open Objectives") &&
!objectivesNav.includes("workspace objectives"),
"Tickets and Objectives should each be a single sidebar link",
!objectivesNav.includes("workspace objectives") &&
mergeRequestsNav.includes("sidebar-nav-section--resource") &&
mergeRequestsNav.includes('class="sidebar-link"') &&
mergeRequestsNav.includes(">Merge Requests</a>") &&
!mergeRequestsNav.includes("All Merge Requests") &&
!mergeRequestsNav.includes("review and integration resources"),
"Tickets, Objectives, and Merge Requests should each be a single primary sidebar link",
);
assert(
ticketsLoad.includes("Object.entries(LANE_STATES)") &&
@@ -295,11 +317,19 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
);
assert(
memoryNav.includes("workspaceRoute(workspaceId, '/memory')") &&
memoryNav.includes("durable workspace memory") &&
memoryNav.includes("workspaceRoute(workspaceId, '/memory/staging')") &&
memoryNav.includes("pending extraction candidates"),
"Memory sidebar section should link to Document and Staging surfaces",
memoryNav.includes('workspaceRoute(workspaceId, "/memory")') &&
memoryNav.includes(
'<h2 class="sidebar-nav-section__header">Memory</h2>',
) &&
memoryNav.includes("Document</a>") &&
memoryNav.includes("sidebar-nav-section--category") &&
memoryNav.includes('class="sidebar-link"') &&
memoryNav.includes('workspaceRoute(workspaceId, "/memory/staging")') &&
memoryNav.includes("Staging</a>") &&
!memoryNav.includes("item-meta") &&
!memoryNav.includes("durable workspace memory") &&
!memoryNav.includes("pending extraction candidates"),
"Memory sidebar section should show Document and Staging as compact single-line links",
);
assert(
memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") &&
@@ -430,7 +460,7 @@ Deno.test("Worker Console expands uncapped tool body from the hover detail actio
"return detailOpen ? (line.expandedBody ?? line.body) : line.body",
) &&
consoleLine.includes("line.toolCallLabel ?? line.toolCall?.name") &&
consoleLine.includes('class={`tool-status') &&
consoleLine.includes("class={`tool-status") &&
consoleLine.includes('class="tool-detail-button"') &&
consoleLine.includes("aria-expanded={detailOpen}") &&
consoleLine.includes("detailOpen = !detailOpen") &&
@@ -513,7 +543,7 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn
"padding: var(--space-3) var(--space-6) var(--space-4)",
) &&
!page.includes("margin-inline: calc(-1 * var(--space-6))") &&
page.includes('import { pushWorkspaceAlert }') &&
page.includes("import { pushWorkspaceAlert }") &&
page.includes('title: "Worker control"') &&
page.includes('title: "Rewind targets"') &&
page.includes('pushWorkspaceAlert("error"') &&
@@ -698,7 +728,9 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
assert(
consolePage.includes("const token = advanceReloadToken();") &&
consolePage.includes("worker = targetWorker;") &&
consolePage.includes("if (!targetWorker) void loadWorker(target, token);") &&
consolePage.includes(
"if (!targetWorker) void loadWorker(target, token);",
) &&
!consolePage.includes("void refreshConsole();\n });\n\n $effect"),
"target-change effect should install route data and guard fallback loading with the new target token",
);
@@ -789,18 +821,21 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
rootLayout.includes("SIDEBAR_CONTEXT") &&
rootLayout.includes("GlobalSidebar") &&
rootLayout.includes("SidebarFrame") &&
rootLayout.includes("{@render sidebar()}") &&
rootLayout.includes("content={sidebar}") &&
!rootLayout.includes("WorkspaceSidebar") &&
rootLayout.includes("workspace-topbar") &&
rootLayout.includes("topbar-icon-button") &&
rootLayout.includes('class="app-shell"') &&
rootLayout.includes('class="app-shell__main"') &&
rootLayout.includes("app-shell__topbar") &&
rootLayout.includes("app-shell__icon-button") &&
rootLayout.includes('href="/account"') &&
rootLayout.includes("Open Account") &&
!sidebar.includes("accountHref") &&
!sidebar.includes("Open Account"),
"Root layout chrome should render a registered sidebar snippet or default global sidebar while account navigation stays in the header",
"Root layout chrome should keep GlobalSidebar as the root slot owner while account navigation stays in the header",
);
assert(
globalSidebar.includes("Global") &&
globalSidebar.includes('aria-label="Global pages"') &&
!globalSidebar.includes('<p class="sidebar-section-label">') &&
globalSidebar.includes("/account") &&
globalSidebar.includes("/login/device") &&
!globalSidebar.includes("Tickets") &&
@@ -810,12 +845,11 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
assert(
workspaceLayout.includes("{#snippet workspaceSidebar()}") &&
workspaceLayout.includes("WorkspaceSidebar") &&
workspaceLayout.includes(
"<SidebarOverride sidebar={workspaceSidebar} />",
) &&
workspaceLayout.includes("controller={parentSidebarController}") &&
workspaceLayout.includes("sidebar={workspaceSidebar}") &&
workspaceLayoutLoad.includes("params.workspaceId") &&
workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"),
"Workspace layout should load workspace data and register a WorkspaceSidebar snippet",
"Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children",
);
assert(
sidebarFrame.includes("let folded = $state(false)") &&
@@ -872,8 +906,8 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection",
"Sidebar and Console should share one Workspace multiplexer and route Worker methods through a subscription lane",
);
assert(
multiplexer.includes('nextMultiplexerId') &&
!multiplexer.includes('crypto.randomUUID'),
multiplexer.includes("nextMultiplexerId") &&
!multiplexer.includes("crypto.randomUUID"),
"Workspace subscription correlation IDs should not require secure-context crypto APIs",
);
assert(
@@ -881,7 +915,9 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection",
multiplexer.includes("this.#sendSubscribe(subscription)") &&
consolePage.includes("const targetWorker = data.worker") &&
consolePage.includes("worker = targetWorker") &&
consolePage.includes("const consoleTarget = $derived({ workspaceId, runtimeId, workerId })"),
consolePage.includes(
"const consoleTarget = $derived({ workspaceId, runtimeId, workerId })",
),
"A reused Console route should subscribe immediately on the live Workspace socket and install the new route Worker",
);
});
@@ -945,7 +981,9 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
consolePage.includes("selectedConsoleProjection.lines") &&
consolePage.includes("selectedConsoleProjection.tasks") &&
consolePage.includes("onSelectWorkerView") &&
consolePage.includes("selectConsoleWorkerView(resolvedSessionId, false)") &&
consolePage.includes(
"selectConsoleWorkerView(resolvedSessionId, false)",
) &&
consolePage.includes("consoleWorkerViewSelectionIsResolved") &&
!consolePage.includes("internal-worker-pane") &&
!consolePage.includes("flattenInternalWorkers"),
@@ -1,33 +1,59 @@
<script lang="ts">
import { page } from '$app/state';
import { buildWorkspaceBreadcrumbs } from './breadcrumb-model';
import { page } from "$app/state";
import WorkspaceSwitcher from "$lib/workspace/sidebar/WorkspaceSwitcher.svelte";
import type { WorkspaceResponse } from "$lib/workspace/sidebar/types";
import { buildWorkspaceBreadcrumbs } from "./breadcrumb-model";
let { workspaceId }: { workspaceId: string } = $props();
type Props = {
workspaceId: string;
workspace?: WorkspaceResponse | null;
workspaceError?: string | null;
};
let { workspaceId, workspace = null, workspaceError = null }: Props = $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;
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)}`);
const breadcrumbs = $derived(
buildWorkspaceBreadcrumbs(page.url.pathname, workspaceId, { workerName }),
);
const currentWorkspaceName = $derived(
workspaceError ? workspaceId : workspace?.display_name || 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>
<div class="workspace-header-location">
<WorkspaceSwitcher
variant="header"
currentWorkspaceId={workspaceId}
{currentWorkspaceName}
/>
{#if breadcrumbs.length > 0}
<span class="workspace-breadcrumb-separator" aria-hidden="true">/</span>
<nav class="workspace-breadcrumbs" aria-label="Current workspace location">
{#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>
{/if}
</div>
<style>
.workspace-header-location,
.workspace-breadcrumbs {
display: flex;
min-width: 0;
@@ -58,16 +84,12 @@
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'] {
.workspace-breadcrumbs span[aria-current="page"] {
color: var(--text-strong);
font-weight: 600;
}
@@ -1,11 +1,13 @@
<script lang="ts">
import type { SidebarSnippet } from './context';
import './sidebar.css';
type Props = {
currentPath: string;
content?: SidebarSnippet | null;
};
const { currentPath }: Props = $props();
const { currentPath, content = null }: Props = $props();
const items = [
{ href: '/', label: 'Workspaces' },
@@ -15,20 +17,23 @@
];
</script>
<div class="global-sidebar" aria-label="Global navigation">
<div class="global-sidebar-section">
<p class="sidebar-section-label">Global</p>
<nav class="sidebar-list" aria-label="Global pages">
{#each items as item}
<a
class="sidebar-link"
class:active={currentPath === item.href}
href={item.href}
aria-current={currentPath === item.href ? 'page' : undefined}
>
<span>{item.label}</span>
</a>
{/each}
</nav>
{#if content}
{@render content()}
{:else}
<div class="global-sidebar" aria-label="Global navigation">
<div class="global-sidebar-section">
<nav class="sidebar-list" aria-label="Global pages">
{#each items as item}
<a
class="sidebar-link"
class:active={currentPath === item.href}
href={item.href}
aria-current={currentPath === item.href ? 'page' : undefined}
>
<span>{item.label}</span>
</a>
{/each}
</nav>
</div>
</div>
</div>
{/if}
@@ -1,28 +1,30 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import { workspaceRoute } from "$lib/workspace/api/http";
type Props = {
currentPath?: string;
workspaceId: string;
};
let { currentPath = '/', workspaceId }: Props = $props();
let documentHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/memory') : '/');
let stagingHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/memory/staging') : '/');
let { currentPath = "/", workspaceId }: Props = $props();
let documentHref = $derived(workspaceId ? workspaceRoute(workspaceId, "/memory") : "/");
let stagingHref = $derived(workspaceId ? workspaceRoute(workspaceId, "/memory/staging") : "/");
</script>
<section class="nav-section">
<header class="section-header">
<span>Memory</span>
</header>
<section class="sidebar-nav-section sidebar-nav-section--category">
<h2 class="sidebar-nav-section__header">Memory</h2>
<a class="objective-link" class:active={currentPath === documentHref} href={documentHref}>
<span class="item-title">Document</span>
<span class="item-meta">durable workspace memory</span>
</a>
<a
class="sidebar-link"
class:active={currentPath === documentHref}
href={documentHref}
aria-current={currentPath === documentHref ? "page" : undefined}
>Document</a>
<a class="objective-link" class:active={currentPath.startsWith(stagingHref)} href={stagingHref}>
<span class="item-title">Staging</span>
<span class="item-meta">pending extraction candidates</span>
</a>
<a
class="sidebar-link"
class:active={currentPath.startsWith(stagingHref)}
href={stagingHref}
aria-current={currentPath.startsWith(stagingHref) ? "page" : undefined}
>Staging</a>
</section>
@@ -10,10 +10,11 @@
let href = $derived(workspaceId ? mergeRequestPagePath(workspaceId) : "/");
</script>
<section class="nav-section">
<header class="section-header"><span>Merge Requests</span></header>
<a class="objective-link" class:active={currentPath.startsWith(href)} {href}>
<span class="item-title">All Merge Requests</span>
<span class="item-meta">review and integration resources</span>
</a>
<section class="sidebar-nav-section sidebar-nav-section--resource">
<a
class="sidebar-link"
class:active={currentPath.startsWith(href)}
{href}
aria-current={currentPath.startsWith(href) ? "page" : undefined}
>Merge Requests</a>
</section>
@@ -10,9 +10,9 @@
let objectivesHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/objectives') : '/objectives');
</script>
<section class="nav-section">
<section class="sidebar-nav-section sidebar-nav-section--resource">
<a
class="primary-nav-link"
class="sidebar-link"
class:active={currentPath.startsWith(objectivesHref)}
href={objectivesHref}
aria-current={currentPath.startsWith(objectivesHref) ? 'page' : undefined}
@@ -0,0 +1,61 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import { SETTINGS_SECTIONS, SETTINGS_ROUTE, settingsSectionHref } from '$lib/workspace/settings/model';
import type { SidebarSnippet } from './context';
let {
workspaceId,
currentPath,
content = null,
}: {
workspaceId: string;
currentPath: string;
content?: SidebarSnippet | null;
} = $props();
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, SETTINGS_ROUTE) : SETTINGS_ROUTE);
function sectionHref(path: string): string {
return workspaceId ? workspaceRoute(workspaceId, path) : path;
}
function isActive(href: string): boolean {
return currentPath === href || currentPath.startsWith(`${href}/`);
}
</script>
<div class="settings-sidebar">
<div class="section-heading">
<h2>Settings</h2>
</div>
{#if content}
{@render content()}
{:else}
<nav class="sidebar-sections" aria-label="Settings sections">
<div class="sidebar-nav-section">
<div class="sidebar-list">
<a
class:active={currentPath === settingsHref}
class="sidebar-link"
href={settingsHref}
aria-current={currentPath === settingsHref ? 'page' : undefined}
>
<span class="sidebar-link-label">Overview</span>
</a>
{#each SETTINGS_SECTIONS as section}
{@const href = sectionHref(settingsSectionHref(section.id))}
<a
class:active={isActive(href)}
class="sidebar-link"
href={href}
aria-current={isActive(href) ? 'page' : undefined}
>
<span class="sidebar-link-label">{section.label}</span>
</a>
{/each}
</div>
</div>
</nav>
{/if}
</div>
@@ -1,45 +0,0 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import { SETTINGS_SECTIONS, SETTINGS_ROUTE, settingsSectionHref } from '$lib/workspace/settings/model';
let { workspaceId, currentPath }: { workspaceId: string; currentPath: string } = $props();
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, SETTINGS_ROUTE) : SETTINGS_ROUTE);
function sectionHref(path: string): string {
return workspaceId ? workspaceRoute(workspaceId, path) : path;
}
function isActive(href: string): boolean {
return currentPath === href || currentPath.startsWith(`${href}/`);
}
</script>
<nav class="sidebar-sections" aria-label="Settings sections">
<div class="nav-section">
<div class="section-heading">
<h2>Settings</h2>
</div>
<div class="sidebar-list">
<a
class:active={currentPath === settingsHref}
class="sidebar-link"
href={settingsHref}
aria-current={currentPath === settingsHref ? 'page' : undefined}
>
<span class="sidebar-link-label">Overview</span>
</a>
{#each SETTINGS_SECTIONS as section}
{@const href = sectionHref(settingsSectionHref(section.id))}
<a
class:active={isActive(href)}
class="sidebar-link"
href={href}
aria-current={isActive(href) ? 'page' : undefined}
>
<span class="sidebar-link-label">{section.label}</span>
</a>
{/each}
</div>
</div>
</nav>
@@ -1,12 +1,17 @@
<script lang="ts">
import { getSidebarController, type SidebarSnippet } from './context';
import {
getSidebarController,
type SidebarController,
type SidebarSnippet,
} from './context';
type Props = {
sidebar: SidebarSnippet;
controller?: SidebarController;
};
const { sidebar }: Props = $props();
const controller = getSidebarController();
const inheritedController = getSidebarController();
const { sidebar, controller = inheritedController }: Props = $props();
$effect(() => controller.registerSidebar(sidebar));
</script>
@@ -10,9 +10,9 @@
let ticketsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/tickets') : '/');
</script>
<section class="nav-section">
<section class="sidebar-nav-section sidebar-nav-section--resource">
<a
class="primary-nav-link"
class="sidebar-link"
class:active={currentPath.startsWith(ticketsHref)}
href={ticketsHref}
aria-current={currentPath.startsWith(ticketsHref) ? 'page' : undefined}
@@ -37,7 +37,7 @@
});
</script>
<section class="nav-section" aria-labelledby="workers-heading">
<section class="sidebar-nav-section" aria-labelledby="workers-heading">
<div class="section-heading-row">
<h2 id="workers-heading">
<a
@@ -1,12 +1,12 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import './sidebar.css';
import { workspaceRoute } from '$lib/workspace/api/http';
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import MemoryNavSection from './MemoryNavSection.svelte';
import MergeRequestsNavSection from './MergeRequestsNavSection.svelte';
import TicketsNavSection from './TicketsNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
import type { WorkspaceResponse } from './types';
type Props = {
@@ -24,15 +24,44 @@
}: Props = $props();
let workspaceId = $derived(workspace?.workspace_id ?? '');
let workspaceHomeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
let workspaceSettingsHref = $derived(
workspaceId ? workspaceRoute(workspaceId, '/settings') : '/',
);
</script>
<div class="workspace-sidebar">
<header class="sidebar-header">
{#if workspace}
<WorkspaceSwitcher
currentWorkspaceId={workspaceId}
currentWorkspaceName={workspace.display_name}
/>
<nav class="workspace-sidebar-shortcuts" aria-label="Workspace shortcuts">
<a
class="workspace-sidebar-shortcut"
class:active={currentPath === workspaceHomeHref}
href={workspaceHomeHref}
aria-label="Workspace home"
title="Workspace home"
aria-current={currentPath === workspaceHomeHref ? 'page' : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m3 11 9-8 9 8"></path>
<path d="M5 10v10h14V10"></path>
<path d="M9 20v-6h6v6"></path>
</svg>
</a>
<a
class="workspace-sidebar-shortcut"
class:active={currentPath.startsWith(workspaceSettingsHref)}
href={workspaceSettingsHref}
aria-label="Workspace settings"
title="Workspace settings"
aria-current={currentPath.startsWith(workspaceSettingsHref) ? 'page' : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 5h16M4 12h16M4 19h16"></path>
<path d="M8 3v4M16 10v4M10 17v4"></path>
</svg>
</a>
</nav>
{:else}
<div class="workspace-label">
<div class="workspace-name">Yoi workspace</div>
@@ -51,8 +80,8 @@
<nav class="sidebar-sections" aria-label="Workspace sections">
<TicketsNavSection {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} />
<MemoryNavSection {currentPath} {workspaceId} />
<MergeRequestsNavSection {currentPath} {workspaceId} />
<MemoryNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} />
</nav>
{/if}
@@ -1,15 +0,0 @@
<script lang="ts">
import { getContext, type Snippet } from 'svelte';
import {
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
type WorkspaceSidebarContentController,
} from './workspace-content-context';
let { content }: { content: Snippet } = $props();
const controller = getContext<WorkspaceSidebarContentController>(
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
);
$effect(() => controller.registerContent(content));
</script>
@@ -8,9 +8,11 @@
let {
currentWorkspaceId,
currentWorkspaceName,
variant = "sidebar",
}: {
currentWorkspaceId: string;
currentWorkspaceName: string;
variant?: "sidebar" | "header";
} = $props();
let workspaces = $state<WorkspaceCatalogRecord[]>([]);
@@ -20,6 +22,7 @@
let root = $state.raw<HTMLDivElement>();
let trigger = $state.raw<HTMLButtonElement>();
let menu = $state.raw<HTMLDivElement>();
const menuId = $derived(`workspace-menu-popover-${variant}`);
const menuWorkspaces = $derived.by(() => {
const entries = workspaces.map((workspace) => ({
@@ -112,14 +115,14 @@
});
</script>
<div class="workspace-menu" bind:this={root}>
<div class="workspace-menu" class:workspace-menu-header={variant === "header"} bind:this={root}>
<button
bind:this={trigger}
type="button"
class="workspace-menu-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-controls="workspace-menu-popover"
aria-controls={menuId}
onclick={toggleMenu}
onkeydown={handleTriggerKeydown}
>
@@ -132,7 +135,7 @@
{#if open}
<div
bind:this={menu}
id="workspace-menu-popover"
id={menuId}
class="workspace-menu-popover"
role="menu"
tabindex="-1"
@@ -75,58 +75,95 @@ Deno.test("sidebar disposers remove only their own registration", () => {
);
});
Deno.test("settings replaces only WorkspaceSidebar content", async () => {
const layoutUrl = new URL(
"../../../routes/w/[workspaceId]/settings/+layout.svelte",
Deno.test("Global, Workspace, and Settings use one recursive sidebar slot contract", async () => {
const rootLayoutUrl = new URL(
"../../../routes/+layout.svelte",
import.meta.url,
);
const workspaceLayoutUrl = new URL(
"../../../routes/w/[workspaceId]/+layout.svelte",
import.meta.url,
);
const settingsLayoutUrl = new URL(
"../../../routes/w/[workspaceId]/settings/+layout.svelte",
import.meta.url,
);
const globalSidebarUrl = new URL("./GlobalSidebar.svelte", import.meta.url);
const workspaceSidebarUrl = new URL(
"./WorkspaceSidebar.svelte",
import.meta.url,
);
const settingsContentUrl = new URL(
"./SettingsSidebarContent.svelte",
const settingsSidebarUrl = new URL(
"./SettingsSidebar.svelte",
import.meta.url,
);
const [layout, workspaceLayout, workspaceSidebar, settingsContent] =
await Promise.all([
Deno.readTextFile(layoutUrl),
Deno.readTextFile(workspaceLayoutUrl),
Deno.readTextFile(workspaceSidebarUrl),
Deno.readTextFile(settingsContentUrl),
]);
const settingsErrorUrl = new URL(
"../../../routes/w/[workspaceId]/settings/+error.svelte",
import.meta.url,
);
const [
rootLayout,
workspaceLayout,
settingsLayout,
globalSidebar,
workspaceSidebar,
settingsSidebar,
settingsError,
] = await Promise.all([
Deno.readTextFile(rootLayoutUrl),
Deno.readTextFile(workspaceLayoutUrl),
Deno.readTextFile(settingsLayoutUrl),
Deno.readTextFile(globalSidebarUrl),
Deno.readTextFile(workspaceSidebarUrl),
Deno.readTextFile(settingsSidebarUrl),
Deno.readTextFile(settingsErrorUrl),
]);
assert(
layout.includes(
"<WorkspaceSidebarContentOverride content={settingsSidebarContent} />",
),
"settings layout should override the WorkspaceSidebar content slot",
rootLayout.includes("<GlobalSidebar") &&
rootLayout.includes("content={sidebar}"),
"root layout should always render GlobalSidebar as the root slot owner",
);
for (
const [name, layout] of [
["Workspace", workspaceLayout],
["Settings", settingsLayout],
] as const
) {
assert(
layout.includes(
"const parentSidebarController = getSidebarController();",
) &&
layout.includes("setContext<SidebarController>(SIDEBAR_CONTEXT") &&
layout.includes("controller={parentSidebarController}"),
`${name} layout should register with its parent and provide the same slot contract to children`,
);
}
assert(
!workspaceLayout.includes("WORKSPACE_SIDEBAR_CONTENT_CONTEXT") &&
!settingsLayout.includes("WorkspaceSidebarContentOverride"),
"recursive slots should not retain Workspace-specific context or override components",
);
assert(
!layout.includes("<SidebarOverride") && !layout.includes("settings-nav"),
"settings layout should not replace the whole sidebar or retain inline navigation",
globalSidebar.includes("{@render content()}") &&
workspaceSidebar.includes("{@render content()}") &&
settingsSidebar.includes("{@render content()}"),
"every sidebar layer should render its child through the same content contract",
);
assert(
workspaceLayout.includes(
"registerContent: sidebarContentOverrides.register",
) &&
workspaceLayout.includes("content={sidebarContent}"),
"workspace layout should provide and project the active child content",
workspaceSidebar.includes('aria-label="Workspace shortcuts"') &&
workspaceSidebar.indexOf('aria-label="Workspace shortcuts"') <
workspaceSidebar.indexOf("{#if content}"),
"WorkspaceSidebar should keep its shortcuts above the recursive child slot",
);
assert(
workspaceSidebar.includes("<WorkspaceSwitcher") &&
workspaceSidebar.indexOf("<WorkspaceSwitcher") <
workspaceSidebar.indexOf("{#if content}") &&
workspaceSidebar.includes("{@render content()}"),
"WorkspaceSidebar should retain its header and render child content below it",
settingsSidebar.includes("SETTINGS_SECTIONS") &&
settingsSidebar.includes('aria-label="Settings sections"'),
"SettingsSidebar should render the authoritative settings catalog as its fallback",
);
assert(
settingsContent.includes("SETTINGS_SECTIONS") &&
settingsContent.includes('aria-label="Settings sections"'),
"SettingsSidebarContent should render the authoritative settings section catalog",
settingsError.includes("This settings page could not be loaded") &&
settingsError.includes("Back to Settings"),
"settings load failures should stay inside the Settings layout and preserve its sidebar",
);
});
@@ -2,6 +2,9 @@
@layer components {
.sidebar-frame {
--sidebar-item-hover: oklch(96% 0 0);
--sidebar-item-active: oklch(92.5% 0 0);
grid-column: 1;
grid-row: 1 / 3;
display: flex;
@@ -13,6 +16,12 @@
padding-block: var(--space-4);
border-right: 1px solid var(--line);
}
@media (prefers-color-scheme: dark) {
.sidebar-frame {
--sidebar-item-hover: oklch(24% 0 0);
--sidebar-item-active: oklch(32% 0 0);
}
}
.sidebar-frame.folded {
width: max-content;
overflow: hidden;
@@ -27,12 +36,14 @@
}
.sidebar-frame-content,
.global-sidebar,
.workspace-sidebar {
.workspace-sidebar,
.settings-sidebar {
min-width: 0;
}
.global-sidebar,
.global-sidebar-section,
.workspace-sidebar {
.workspace-sidebar,
.settings-sidebar {
display: grid;
gap: var(--space-2);
}
@@ -42,6 +53,40 @@
margin-bottom: var(--space-2);
min-width: 0;
}
.workspace-sidebar-shortcuts {
display: flex;
align-items: center;
gap: var(--space-1);
}
.workspace-sidebar-shortcut {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-soft);
color: var(--text-muted);
text-decoration: none;
}
.workspace-sidebar-shortcut > svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.workspace-sidebar-shortcut:hover,
.workspace-sidebar-shortcut:focus-visible {
background: var(--sidebar-item-hover);
}
.workspace-sidebar-shortcut.active {
background: var(--sidebar-item-active);
}
.workspace-sidebar-shortcut:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 1px;
}
.sidebar-control-row {
display: flex;
justify-content: flex-end;
@@ -77,6 +122,10 @@
position: relative;
min-width: 0;
}
.workspace-menu-header {
flex: 0 1 auto;
max-width: min(28vw, 20rem);
}
.workspace-menu-trigger {
display: flex;
align-items: center;
@@ -119,6 +168,21 @@
.workspace-menu-trigger[aria-expanded="true"] > svg {
transform: rotate(180deg);
}
.workspace-menu-header .workspace-menu-trigger {
width: auto;
max-width: 100%;
padding: 0.35rem 0.45rem;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.84rem;
font-weight: 650;
line-height: 1;
}
.workspace-menu-header .workspace-menu-popover {
inset-inline-start: 0;
inset-inline-end: auto;
width: min(20rem, 72vw);
}
.workspace-menu-trigger:hover,
.workspace-menu-trigger:focus-visible {
background: var(--interactive-hover);
@@ -165,12 +229,11 @@
white-space: nowrap;
}
.workspace-menu-item:hover,
.workspace-menu-item:focus-visible,
.workspace-menu-item.current {
background: var(--interactive-hover);
.workspace-menu-item:focus-visible {
background: var(--sidebar-item-hover);
}
.workspace-menu-item.current {
color: var(--accent);
background: var(--sidebar-item-active);
}
.workspace-menu-separator {
height: 1px;
@@ -268,22 +331,22 @@
}
.sidebar-sections {
display: grid;
gap: var(--space-3);
gap: var(--space-1);
min-width: 0;
}
.nav-section {
.sidebar-nav-section {
display: grid;
gap: var(--space-2);
gap: var(--space-1);
}
.section-heading-row,
.section-header {
.sidebar-nav-section__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.section-heading-row h2,
.section-header,
.sidebar-nav-section__header,
.sidebar-section-label {
color: var(--text-faint);
font-size: 0.72rem;
@@ -292,17 +355,21 @@
text-transform: uppercase;
}
.section-heading-row h2,
.sidebar-nav-section__header,
.sidebar-section-label {
margin: 0;
}
.section-heading-link {
border-radius: var(--radius-soft);
color: inherit;
text-decoration: none;
}
.section-heading-link:hover,
.section-heading-link:focus-visible,
.section-heading-link:focus-visible {
background: var(--sidebar-item-hover);
}
.section-heading-link.active {
color: var(--accent);
background: var(--sidebar-item-active);
}
.section-count {
color: var(--text-muted);
@@ -317,61 +384,31 @@
padding: 0;
list-style: none;
}
.primary-nav-link,
.nav-item,
.objective-link,
.sidebar-link {
display: grid;
gap: 3px;
min-width: 0;
margin-inline: calc(-1 * var(--space-2));
margin: 0;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-soft);
color: inherit;
color: var(--text-strong);
font-size: 0.9rem;
text-align: left;
text-decoration: none;
transition: background-color 140ms ease, color 140ms ease;
}
.primary-nav-link {
color: var(--text-strong);
font-size: 0.9rem;
font-weight: 700;
.sidebar-nav-section--category {
padding-block: var(--space-1);
}
.sidebar-nav-section--category > .sidebar-link {
margin-left: var(--space-3);
}
a.primary-nav-link:hover,
a.primary-nav-link:focus-visible,
a.nav-item:hover,
a.nav-item:focus-visible,
a.objective-link:hover,
a.objective-link:focus-visible,
a.sidebar-link:hover,
a.sidebar-link:focus-visible {
background: var(--interactive-hover);
background: var(--sidebar-item-hover);
}
a.primary-nav-link.active,
a.nav-item.active,
a.objective-link.active,
a.sidebar-link.active {
background: var(--interactive-selected);
}
a.primary-nav-link.active,
a.nav-item.active .item-title,
a.objective-link.active .item-title,
a.sidebar-link.active {
color: var(--accent);
}
.item-title,
.item-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-title {
color: var(--text-strong);
font-weight: 650;
}
.item-meta {
color: var(--text-muted);
font-size: 0.82rem;
background: var(--sidebar-item-active);
}
.worker-nav-link {
display: grid;
@@ -386,12 +423,10 @@
text-decoration: none;
}
.worker-nav-link:hover {
background: var(--interactive-hover);
color: var(--text-strong);
background: var(--sidebar-item-hover);
}
.worker-nav-link.active {
background: var(--interactive-selected);
color: var(--accent);
background: var(--sidebar-item-active);
}
.worker-status-indicator {
grid-column: 1;
@@ -1,9 +0,0 @@
import type { Snippet } from "svelte";
export const WORKSPACE_SIDEBAR_CONTENT_CONTEXT = Symbol(
"workspace-sidebar-content",
);
export type WorkspaceSidebarContentController = {
registerContent(content: Snippet): () => void;
};
@@ -13,6 +13,15 @@ const switcherSource = await Deno.readTextFile(
const sidebarSource = await Deno.readTextFile(
new URL("./WorkspaceSidebar.svelte", import.meta.url),
);
const sidebarStyles = await Deno.readTextFile(
new URL("./sidebar.css", import.meta.url),
);
const headerSource = await Deno.readTextFile(
new URL("../header/WorkspaceBreadcrumbs.svelte", import.meta.url),
);
const workspaceLayoutSource = await Deno.readTextFile(
new URL("../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
);
Deno.test("workspace name opens the settings and workspace menu", () => {
assert(
@@ -39,6 +48,10 @@ Deno.test("workspace name opens the settings and workspace menu", () => {
switcherSource.includes("currentWorkspaceName"),
"trigger does not use the name",
);
assert(
switcherSource.includes("workspace-menu-popover-${variant}"),
"sidebar and header instances should use distinct menu ids",
);
assert(!switcherSource.includes("<select"), "legacy select switcher remains");
});
@@ -61,14 +74,51 @@ Deno.test("workspace menu lists catalog entries and marks the current workspace"
);
});
Deno.test("workspace sidebar uses the workspace name menu as its header", () => {
Deno.test("workspace header prefixes breadcrumbs with the same Workspace menu", () => {
assert(
sidebarSource.includes("<WorkspaceSwitcher"),
"sidebar omits the menu",
headerSource.includes("<WorkspaceSwitcher") &&
headerSource.includes('variant="header"') &&
headerSource.includes("currentWorkspaceName"),
"header should render the shared Workspace selector before breadcrumbs",
);
assert(
sidebarSource.includes("currentWorkspaceName={workspace.display_name}"),
"sidebar does not pass the current Workspace name",
headerSource.indexOf("<WorkspaceSwitcher") <
headerSource.indexOf('<nav class="workspace-breadcrumbs"'),
"Workspace selector should precede the breadcrumb trail",
);
assert(
workspaceLayoutSource.includes("workspace={data.workspace ?? null}") &&
workspaceLayoutSource.includes(
"workspaceError={data.workspaceError ?? null}",
),
"Workspace layout should pass the authoritative Workspace name to the header selector",
);
});
Deno.test("WorkspaceSidebar exposes icon-only home and settings shortcuts", () => {
assert(
sidebarSource.includes('aria-label="Workspace shortcuts"') &&
sidebarSource.includes('aria-label="Workspace home"') &&
sidebarSource.includes('aria-label="Workspace settings"') &&
sidebarSource.includes("workspaceRoute(workspaceId)") &&
sidebarSource.includes("workspaceRoute(workspaceId, '/settings')") &&
sidebarStyles.includes(".workspace-sidebar-shortcut.active"),
"Workspace Sidebar should expose accessible Home and Settings icon links",
);
assert(
!headerSource.includes("workspace-sidebar-shortcut"),
"Workspace shortcuts should remain specific to the Sidebar",
);
});
Deno.test("Workspace selector remains in the Header only", () => {
assert(
!sidebarSource.includes("<WorkspaceSwitcher"),
"Workspace Sidebar should not render the Workspace selector",
);
assert(
headerSource.includes("<WorkspaceSwitcher"),
"Header should retain the shared Workspace selector",
);
assert(
!sidebarSource.includes('class="sidebar-actions-row"'),
+24 -25
View File
@@ -25,34 +25,30 @@
<WorkspaceAlerts />
<div class="workspace-layout">
<div class="app-shell">
<SidebarFrame>
{#if sidebar}
{@render sidebar()}
{:else}
<GlobalSidebar currentPath={page.url.pathname} />
{/if}
<GlobalSidebar currentPath={page.url.pathname} content={sidebar} />
</SidebarFrame>
<header class="workspace-topbar">
<div class="workspace-topbar-location">
<header class="app-shell__topbar">
<div class="app-shell__topbar-location">
{#if headerController.content}{@render headerController.content()}{/if}
</div>
<nav class="workspace-topbar-actions" aria-label="Global navigation">
<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">
<nav class="app-shell__topbar-actions" aria-label="Global navigation">
<a class="app-shell__icon-button" href="/account" aria-label="Open Account" title="Account">
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
</nav>
</header>
<main class="shell">
<main class="app-shell__main">
{@render children()}
</main>
</div>
<style>
.workspace-layout {
.app-shell {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
@@ -64,7 +60,9 @@
min-width: 0;
}
.workspace-topbar {
.app-shell__topbar {
position: relative;
z-index: 30;
grid-column: 2;
grid-row: 1;
display: flex;
@@ -79,18 +77,19 @@
backdrop-filter: blur(14px);
}
.workspace-topbar-location {
.app-shell__topbar-location {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
overflow: visible;
}
.workspace-topbar-actions {
.app-shell__topbar-actions {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.topbar-icon-button {
.app-shell__icon-button {
display: inline-flex;
width: 2.35rem;
height: 2.35rem;
@@ -101,13 +100,13 @@
text-decoration: none;
}
.topbar-icon-button:hover,
.topbar-icon-button:focus-visible {
.app-shell__icon-button:hover,
.app-shell__icon-button:focus-visible {
background: var(--interactive-hover);
color: var(--text-muted);
}
.topbar-icon {
.app-shell__icon {
width: 1.1rem;
height: 1.1rem;
fill: none;
@@ -117,7 +116,7 @@
stroke-linejoin: round;
}
.shell {
.app-shell__main {
grid-column: 2;
grid-row: 2;
display: flex;
@@ -133,7 +132,7 @@
}
@media (max-width: 760px) {
.workspace-layout {
.app-shell {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr;
width: 100vw;
@@ -142,13 +141,13 @@
overflow: visible;
}
.workspace-topbar {
.app-shell__topbar {
grid-column: 1;
grid-row: 2;
padding: 0 var(--space-4);
}
.shell {
.app-shell__main {
grid-column: 1;
grid-row: 3;
overflow: visible;
@@ -1,14 +1,16 @@
<script lang="ts">
import { setContext, type Snippet } from 'svelte';
import { setContext } from 'svelte';
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 { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import {
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
type WorkspaceSidebarContentController,
} from '$lib/workspace/sidebar/workspace-content-context';
getSidebarController,
SIDEBAR_CONTEXT,
type SidebarController,
type SidebarSnippet,
} from '$lib/workspace/sidebar/context';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
import '$lib/workspace/styles/workspace-pages.css';
@@ -17,13 +19,14 @@
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
let sidebarContent = $state<Snippet | null>(null);
const sidebarContentOverrides = createOverrideStack<Snippet>((activeContent) => {
const parentSidebarController = getSidebarController();
let sidebarContent = $state<SidebarSnippet | null>(null);
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
sidebarContent = activeContent;
});
setContext<WorkspaceSidebarContentController>(WORKSPACE_SIDEBAR_CONTENT_CONTEXT, {
registerContent: sidebarContentOverrides.register,
setContext<SidebarController>(SIDEBAR_CONTEXT, {
registerSidebar: sidebarContentOverrides.register,
});
$effect(() => {
@@ -34,7 +37,11 @@
</script>
{#snippet workspaceHeader()}
<WorkspaceBreadcrumbs workspaceId={page.params.workspaceId ?? data.workspace?.workspace_id ?? ''} />
<WorkspaceBreadcrumbs
workspaceId={page.params.workspaceId ?? data.workspace?.workspace_id ?? ''}
workspace={data.workspace ?? null}
workspaceError={data.workspaceError ?? null}
/>
{/snippet}
{#snippet workspaceSidebar()}
@@ -47,6 +54,6 @@
{/snippet}
<HeaderOverride content={workspaceHeader} />
<SidebarOverride sidebar={workspaceSidebar} />
<SidebarOverride controller={parentSidebarController} sidebar={workspaceSidebar} />
{@render children()}
@@ -0,0 +1,25 @@
<script lang="ts">
import { page } from '$app/state';
import { workspaceRoute } from '$lib/workspace/api/http';
let settingsHref = $derived(
page.params.workspaceId
? workspaceRoute(page.params.workspaceId, '/settings')
: '/settings',
);
</script>
<svelte:head>
<title>Settings unavailable · Yoi Workspace</title>
</svelte:head>
<section class="settings-panel" aria-labelledby="settings-error-title">
<p class="eyebrow">Settings</p>
<h1 id="settings-error-title">This settings page could not be loaded</h1>
<p class="section-state error">
{page.error?.message ?? 'The Backend rejected or could not complete the settings request.'}
</p>
<div class="settings-action-row">
<a class="button-link" href={settingsHref}>Back to Settings</a>
</div>
</section>
@@ -1,21 +1,39 @@
<script lang="ts">
import { page } from '$app/state';
import SettingsSidebarContent from '$lib/workspace/sidebar/SettingsSidebarContent.svelte';
import WorkspaceSidebarContentOverride from '$lib/workspace/sidebar/WorkspaceSidebarContentOverride.svelte';
import { setContext } from 'svelte';
import SettingsSidebar from '$lib/workspace/sidebar/SettingsSidebar.svelte';
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
import {
getSidebarController,
SIDEBAR_CONTEXT,
type SidebarController,
type SidebarSnippet,
} from '$lib/workspace/sidebar/context';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import '$lib/workspace/styles/settings.css';
import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props();
const parentSidebarController = getSidebarController();
let sidebarContent = $state<SidebarSnippet | null>(null);
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
sidebarContent = activeContent;
});
setContext<SidebarController>(SIDEBAR_CONTEXT, {
registerSidebar: sidebarContentOverrides.register,
});
</script>
{#snippet settingsSidebarContent()}
<SettingsSidebarContent
{#snippet settingsSidebar()}
<SettingsSidebar
workspaceId={page.params.workspaceId ?? ''}
currentPath={page.url.pathname}
content={sidebarContent}
/>
{/snippet}
<WorkspaceSidebarContentOverride content={settingsSidebarContent} />
<SidebarOverride controller={parentSidebarController} sidebar={settingsSidebar} />
<section class="settings-page">
{@render children()}