feat: integrate workspace switching

This commit is contained in:
2026-08-21 04:18:32 +09:00
22 changed files with 1516 additions and 159 deletions
@@ -38,25 +38,31 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
);
});
Deno.test("root layout bootstraps only the scoped workspace entry", async () => {
Deno.test("root layout leaves Workspace selection explicit", async () => {
const layout = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url),
);
assert(
layout.includes('loadJson<WorkspaceResponse>(fetch, "/api/workspace")'),
"unscoped layout may use only the workspace-id bootstrap endpoint",
!layout.includes("/api/workspace") &&
!layout.includes("redirect(") &&
layout.includes("Workspace selection is explicit"),
"root layout must not infer or redirect to a singleton Workspace",
);
});
Deno.test("Workspace route changes dispose old multiplexed subscription state", async () => {
const [layout, multiplexer] = await Promise.all([
Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
),
Deno.readTextFile(new URL("./../multiplexer.ts", import.meta.url)),
]);
assert(
layout.includes("throw redirect(307") &&
layout.includes("workspaceRoute(workspace.data.workspace_id)") &&
!layout.includes("scopedCompatibilityRoute") &&
!layout.includes("workspaceRoute(workspaceId, pathname)"),
"root layout should redirect only to the scoped workspace entry",
);
assert(
!layout.includes("`/api${path}`") &&
!layout.includes('"/api/repositories"'),
"layout must not fall back to unscoped workspace-scoped API calls",
layout.includes("disposeWorkspaceMultiplexer(workspaceId)") &&
multiplexer.includes("multiplexers.delete(workspaceId)") &&
multiplexer.includes("this.#subscriptions.clear()") &&
multiplexer.includes("this.#socket?.close()"),
"changing Workspace must dispose old subscriptions and transport state",
);
});
@@ -0,0 +1,164 @@
export type WorkspaceCatalogRecord = {
workspace_id: string;
owner_account_id: string | null;
display_name: string;
state: string;
created_at: string;
updated_at: string;
};
export type WorkspaceRepositoryRecord = {
workspace_id: string;
repository_id: string;
name: string;
kind: string;
uri: string;
default_ref: string | null;
};
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
repositories: WorkspaceRepositoryRecord[];
repository_error?: string;
};
export type CreateWorkspaceRequest = {
operation_key: string;
display_name: string;
repository: {
uri: string;
display_name: string | null;
default_ref: string | null;
};
};
export type CreateWorkspaceResponse = {
workspace: WorkspaceCatalogRecord;
repository: WorkspaceRepositoryRecord;
config_revision: number;
request_fingerprint: string;
replayed: boolean;
};
export class WorkspaceCatalogError extends Error {
constructor(
public readonly status: number | null,
message: string,
) {
super(message);
this.name = "WorkspaceCatalogError";
}
}
type Fetch = typeof globalThis.fetch;
export async function listWorkspaces(
fetcher: Fetch,
): Promise<WorkspaceCatalogRecord[]> {
return await fetchJson<WorkspaceCatalogRecord[]>(
fetcher,
"/api/workspaces?limit=200",
);
}
export async function listWorkspaceRepositories(
fetcher: Fetch,
workspaceId: string,
): Promise<WorkspaceRepositoryRecord[]> {
return await fetchJson<WorkspaceRepositoryRecord[]>(
fetcher,
`/api/w/${encodeURIComponent(workspaceId)}/repositories`,
);
}
export async function loadWorkspaceCatalog(
fetcher: Fetch,
): Promise<WorkspaceCatalogItem[]> {
const workspaces = await listWorkspaces(fetcher);
return await Promise.all(
workspaces.map(async (workspace) => {
try {
return {
...workspace,
repositories: await listWorkspaceRepositories(
fetcher,
workspace.workspace_id,
),
};
} catch (error) {
return {
...workspace,
repositories: [],
repository_error: errorMessage(error),
};
}
}),
);
}
export async function createWorkspace(
fetcher: Fetch,
request: CreateWorkspaceRequest,
): Promise<CreateWorkspaceResponse> {
return await fetchJson<CreateWorkspaceResponse>(fetcher, "/api/workspaces", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
});
}
export function creationErrorMessage(error: unknown): string {
if (!(error instanceof WorkspaceCatalogError)) {
return `Network error. The same operation can be retried safely. ${
errorMessage(error)
}`;
}
switch (error.status) {
case 400:
return `Validation failed. ${error.message}`;
case 401:
case 403:
return `You are not authorized to create this Workspace. ${error.message}`;
case 409:
return `Creation conflicts with current Backend state. ${error.message}`;
default:
return `Workspace creation failed. The same operation can be retried safely. ${error.message}`;
}
}
export function createOperationKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return `web-workspace-create-${crypto.randomUUID()}`;
}
return `web-workspace-create-${Date.now()}-${
Math.random().toString(16).slice(2)
}`;
}
async function fetchJson<T>(
fetcher: Fetch,
input: string,
init?: RequestInit,
): Promise<T> {
let response: Response;
try {
response = await fetcher(input, init);
} catch (error) {
throw new WorkspaceCatalogError(null, errorMessage(error));
}
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`.trim();
try {
const body = await response.json();
if (typeof body?.message === "string") detail = body.message;
else if (typeof body?.error === "string") detail = body.error;
} catch {
// Preserve the bounded status text when the Backend did not return JSON.
}
throw new WorkspaceCatalogError(response.status, detail);
}
return await response.json() as T;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -42,6 +42,13 @@ export function workspaceMultiplexer(workspaceId: string): WorkspaceMultiplexer
return multiplexer;
}
export function disposeWorkspaceMultiplexer(workspaceId: string): void {
const multiplexer = multiplexers.get(workspaceId);
if (!multiplexer) return;
multiplexers.delete(workspaceId);
multiplexer.dispose();
}
export class WorkspaceMultiplexer {
readonly #workspaceId: string;
readonly #subscriptions = new Map<string, ActiveSubscription>();
@@ -219,6 +226,22 @@ export class WorkspaceMultiplexer {
}
}
dispose(): void {
this.#closed = true;
if (this.#reconnectTimer) {
clearTimeout(this.#reconnectTimer);
this.#reconnectTimer = null;
}
for (const subscription of this.#subscriptions.values()) {
subscription.listener.onStatus?.('closed', 'Workspace selection changed');
}
this.#subscriptions.clear();
this.#requests.clear();
this.#runtimeSubscriptions.clear();
this.#socket?.close();
this.#socket = null;
}
#send(frame: SubscriptionFrame): void {
if (this.#socket?.readyState !== WebSocket.OPEN) return;
this.#socket.send(JSON.stringify(frame));
@@ -8,6 +8,8 @@
const { currentPath }: Props = $props();
const items = [
{ href: '/', label: 'Workspaces' },
{ href: '/#workspace-create-title', label: 'Create Workspace' },
{ href: '/account', label: 'Account' },
{ href: '/login/device', label: 'Device Login' },
];
@@ -6,6 +6,7 @@
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import TicketsNavSection from './TicketsNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
import type { RepositoryListResponse, WorkspaceResponse } from './types';
type Props = {
@@ -76,6 +77,8 @@
</div>
</header>
{#if workspaceId}<WorkspaceSwitcher currentWorkspaceId={workspaceId} />{/if}
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
<TicketsNavSection {currentPath} {workspaceId} />
@@ -0,0 +1,55 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import {
listWorkspaces,
type WorkspaceCatalogRecord,
} from "$lib/workspace/api/workspace-catalog";
import "$lib/workspace/styles/workspace-catalog.css";
let { currentWorkspaceId } = $props<{ currentWorkspaceId: string }>();
let workspaces = $state<WorkspaceCatalogRecord[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
try {
workspaces = await listWorkspaces(fetch);
} catch (cause) {
error = cause instanceof Error ? cause.message : String(cause);
} finally {
loading = false;
}
});
async function switchWorkspace(event: Event) {
const workspaceId = (event.currentTarget as HTMLSelectElement).value;
if (!workspaceId || workspaceId === currentWorkspaceId) return;
await goto(`/w/${encodeURIComponent(workspaceId)}`);
}
</script>
<div class="workspace-switcher">
<label for="workspace-switcher-select">Workspace</label>
<select
id="workspace-switcher-select"
value={currentWorkspaceId}
onchange={switchWorkspace}
disabled={loading}
aria-label="Switch Workspace"
>
{#if !workspaces.some((workspace) => workspace.workspace_id === currentWorkspaceId)}
<option value={currentWorkspaceId}>
{loading ? "Loading current Workspace…" : "Current Workspace unavailable"}
</option>
{/if}
{#each workspaces as workspace (workspace.workspace_id)}
<option value={workspace.workspace_id}>{workspace.display_name}</option>
{/each}
</select>
<div class="workspace-switcher-actions">
<a href="/">All Workspaces</a>
<a href="/#workspace-create-title">Create</a>
</div>
{#if error}<span class="workspace-switcher-error">Selector unavailable: {error}</span>{/if}
</div>
@@ -0,0 +1,209 @@
@layer components {
.workspace-catalog-shell {
width: min(1120px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 5rem;
display: grid;
gap: 2.5rem;
}
.workspace-catalog-heading,
.workspace-card-heading,
.workspace-create-row,
.workspace-switcher-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.workspace-catalog-heading h1,
.workspace-create-panel h2,
.workspace-catalog-shell h2 {
margin: 0.2rem 0 0.45rem;
}
.workspace-catalog-heading p,
.workspace-create-panel p,
.workspace-empty-state p {
color: var(--text-muted);
margin: 0;
}
.workspace-catalog-eyebrow {
color: var(--accent) !important;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.workspace-catalog-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.workspace-catalog-card,
.workspace-create-panel,
.workspace-empty-state {
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--bg-raised);
}
.workspace-catalog-card {
color: inherit;
padding: 1.1rem;
display: grid;
gap: 0.65rem;
text-decoration: none;
}
.workspace-catalog-card:hover,
.workspace-catalog-card:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.workspace-catalog-card code,
.workspace-catalog-card small,
.workspace-repository-summary small {
color: var(--text-muted);
}
.workspace-card-heading > span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.15rem 0.5rem;
font-size: 0.7rem;
text-transform: uppercase;
}
.workspace-card-heading > .workspace-state-active {
border-color: color-mix(in srgb, var(--success) 50%, var(--line));
color: var(--success);
}
.workspace-repository-summary {
display: grid;
gap: 0.2rem;
}
.workspace-empty-state {
padding: 1.5rem;
margin-top: 1rem;
}
.workspace-create-panel {
padding: 1.5rem;
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr);
gap: 2rem;
}
.workspace-create-panel form {
display: grid;
gap: 1rem;
}
.workspace-create-panel label {
display: grid;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.8rem;
}
.workspace-create-panel input,
.workspace-switcher select {
min-width: 0;
border: 1px solid var(--line);
border-radius: 0.45rem;
background: var(--bg);
color: var(--text);
padding: 0.65rem 0.75rem;
font: inherit;
}
.workspace-create-row > label {
flex: 1;
}
.workspace-primary-action,
.workspace-secondary-action {
border-radius: 0.45rem;
padding: 0.65rem 0.9rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.workspace-primary-action {
border: 1px solid var(--accent);
background: var(--accent);
color: var(--bg);
}
.workspace-secondary-action {
border: 1px solid var(--line);
background: var(--bg-raised);
color: var(--text);
}
.workspace-primary-action:disabled,
.workspace-secondary-action:disabled {
opacity: 0.55;
cursor: wait;
}
.workspace-catalog-alert {
border-left: 3px solid var(--danger);
background: color-mix(in srgb, var(--danger) 8%, transparent);
padding: 0.75rem 0.9rem;
color: var(--text);
}
.workspace-switcher {
display: grid;
gap: 0.45rem;
padding: 0 0.75rem 0.85rem;
border-bottom: 1px solid var(--line);
}
.workspace-switcher label {
font-size: 0.68rem;
letter-spacing: 0.08em;
color: var(--text-muted);
text-transform: uppercase;
}
.workspace-switcher select {
width: 100%;
padding: 0.45rem 0.55rem;
font-size: 0.78rem;
}
.workspace-switcher-actions {
display: flex;
gap: 0.75rem;
font-size: 0.72rem;
}
.workspace-switcher-error {
color: var(--danger);
font-size: 0.7rem;
}
@media (max-width: 760px) {
.workspace-create-panel {
grid-template-columns: 1fr;
}
.workspace-create-row,
.workspace-catalog-heading {
align-items: stretch;
flex-direction: column;
}
}
}
+4 -24
View File
@@ -1,25 +1,5 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceRoute } from "$lib/workspace/api/http";
import type { WorkspaceResponse } from "$lib/workspace/sidebar/types";
import type { LayoutLoad } from "./$types";
import type { LayoutLoad } from './$types';
export const ssr = false;
export const prerender = false;
export const load: LayoutLoad = async ({ fetch, params, url }) => {
if (params.workspaceId) {
return {};
}
const publicRoutes = new Set(["/account", "/login/device"]);
if (publicRoutes.has(url.pathname)) {
return {};
}
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
if (workspace.data) {
const scopedPath = workspaceRoute(workspace.data.workspace_id);
throw redirect(307, `${scopedPath}${url.search}`);
}
return {};
};
// Workspace selection is explicit at `/`; the root layout must never infer a
// singleton Workspace or redirect based on an unscoped compatibility endpoint.
export const load: LayoutLoad = () => ({});
+188 -5
View File
@@ -1,6 +1,189 @@
<main class="workspace-panel-shell">
<section class="workspace-card">
<h1>Redirecting to scoped workspace…</h1>
<p class="section-note">The workspace entry bootstraps the current workspace id and opens the canonical <code>/w/&lt;workspace-id&gt;</code> route.</p>
<script lang="ts">
import { goto } from "$app/navigation";
import {
createOperationKey,
createWorkspace,
creationErrorMessage,
loadWorkspaceCatalog,
type CreateWorkspaceRequest,
type WorkspaceCatalogItem,
} from "$lib/workspace/api/workspace-catalog";
import "$lib/workspace/styles/workspace-catalog.css";
let { data } = $props();
let workspaces = $state<WorkspaceCatalogItem[]>([]);
let catalogError = $state<string | null>(null);
let refreshing = $state(false);
let creating = $state(false);
let creationError = $state<string | null>(null);
let displayName = $state("");
let repositoryUri = $state("");
let repositoryName = $state("Main");
let defaultRef = $state("");
let lastSubmission = $state<{
signature: string;
request: CreateWorkspaceRequest;
} | null>(null);
$effect(() => {
workspaces = data.workspaces;
catalogError = data.catalogError;
});
async function refreshCatalog() {
refreshing = true;
catalogError = null;
try {
workspaces = await loadWorkspaceCatalog(fetch);
} catch (error) {
catalogError = error instanceof Error ? error.message : String(error);
} finally {
refreshing = false;
}
}
async function submitCreation(event: SubmitEvent) {
event.preventDefault();
if (creating) return;
const normalized = {
displayName: displayName.trim(),
repositoryUri: repositoryUri.trim(),
repositoryName: repositoryName.trim(),
defaultRef: defaultRef.trim(),
};
const signature = JSON.stringify(normalized);
const request = lastSubmission?.signature === signature
? lastSubmission.request
: {
operation_key: createOperationKey(),
display_name: normalized.displayName,
repository: {
uri: normalized.repositoryUri,
display_name: normalized.repositoryName || null,
default_ref: normalized.defaultRef || null,
},
};
lastSubmission = { signature, request };
creating = true;
creationError = null;
try {
const response = await createWorkspace(fetch, request);
await goto(`/w/${encodeURIComponent(response.workspace.workspace_id)}`);
} catch (error) {
creationError = creationErrorMessage(error);
} finally {
creating = false;
}
}
function formatUpdated(value: string): string {
const timestamp = Date.parse(value);
return Number.isNaN(timestamp)
? value
: new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(timestamp);
}
</script>
<svelte:head>
<title>Workspaces · Yoi</title>
</svelte:head>
<div class="workspace-catalog-shell">
<section class="workspace-catalog-heading">
<div>
<p class="workspace-catalog-eyebrow">Backend</p>
<h1>Workspaces</h1>
<p>Select an accessible team space or create one on this Backend.</p>
</div>
<button class="workspace-secondary-action" onclick={refreshCatalog} disabled={refreshing}>
{refreshing ? "Refreshing…" : "Refresh"}
</button>
</section>
</main>
{#if catalogError}
<div class="workspace-catalog-alert" role="alert">
Refresh failed. Existing results were kept. {catalogError}
</div>
{/if}
<section aria-labelledby="workspace-list-title">
<h2 id="workspace-list-title">Available Workspaces</h2>
{#if workspaces.length === 0}
<div class="workspace-empty-state">
<strong>No accessible Workspaces</strong>
<p>Create the first Workspace if you have Backend permission.</p>
</div>
{:else}
<div class="workspace-catalog-grid">
{#each workspaces as workspace (workspace.workspace_id)}
<a
class="workspace-catalog-card"
href={`/w/${encodeURIComponent(workspace.workspace_id)}`}
>
<span class="workspace-card-heading">
<strong>{workspace.display_name}</strong>
<span class:workspace-state-active={workspace.state === "active"}>
{workspace.state}
</span>
</span>
<code>{workspace.workspace_id}</code>
{#if workspace.repositories[0]}
<span class="workspace-repository-summary">
{workspace.repositories[0].name}
<small>
{workspace.repositories[0].default_ref ?? "repository default"} ·
{workspace.repositories[0].kind}
</small>
</span>
{:else if workspace.repository_error}
<small>Repository summary unavailable</small>
{:else}
<small>No repositories</small>
{/if}
<small>Updated {formatUpdated(workspace.updated_at)}</small>
</a>
{/each}
</div>
{/if}
</section>
<section class="workspace-create-panel" aria-labelledby="workspace-create-title">
<div>
<p class="workspace-catalog-eyebrow">New team space</p>
<h2 id="workspace-create-title">Create Workspace</h2>
<p>
Repository paths and URIs are interpreted by the Backend. Browser-local paths are
not authority.
</p>
</div>
<form onsubmit={submitCreation}>
<label>
Workspace display name
<input bind:value={displayName} required autocomplete="off" />
</label>
<label>
Initial repository absolute path or URI
<input bind:value={repositoryUri} required autocomplete="off" />
</label>
<div class="workspace-create-row">
<label>
Repository display name
<input bind:value={repositoryName} autocomplete="off" />
</label>
<label>
Default ref
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
</label>
</div>
{#if creationError}
<div class="workspace-catalog-alert" role="alert">{creationError}</div>
{/if}
<button class="workspace-primary-action" type="submit" disabled={creating}>
{creating ? "Creating…" : creationError ? "Retry creation" : "Create Workspace"}
</button>
</form>
</section>
</div>
+14 -1
View File
@@ -1,3 +1,16 @@
import type { PageLoad } from "./$types";
import { loadWorkspaceCatalog } from "$lib/workspace/api/workspace-catalog";
export const load: PageLoad = async () => ({});
export const load: PageLoad = async ({ fetch }) => {
try {
return {
workspaces: await loadWorkspaceCatalog(fetch),
catalogError: null,
};
} catch (error) {
return {
workspaces: [],
catalogError: error instanceof Error ? error.message : String(error),
};
}
};
@@ -0,0 +1,23 @@
<script lang="ts">
import { page } from "$app/state";
import "$lib/workspace/styles/workspace-catalog.css";
const workspaceId = $derived(page.params.workspaceId ?? "unknown");
</script>
<svelte:head><title>Workspace unavailable · Yoi</title></svelte:head>
<div class="workspace-catalog-shell">
<section class="workspace-empty-state">
<p class="workspace-catalog-eyebrow">Workspace unavailable</p>
<h1>The selected Workspace cannot be opened</h1>
<p>
<code>{workspaceId}</code> may have been removed, become inaccessible, or no longer exist on
this Backend. No state from a previously selected Workspace was retained.
</p>
<div class="workspace-switcher-actions">
<a href="/">Choose another Workspace</a>
<a href="/#workspace-create-title">Create Workspace</a>
</div>
</section>
</div>
@@ -3,6 +3,7 @@
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 { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
import '$lib/workspace/styles/workspace-pages.css';
import '$lib/workspace/styles/tickets.css';
@@ -10,6 +11,11 @@
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
$effect(() => {
const workspaceId = data.workspace?.workspace_id;
if (!workspaceId) return;
return () => disposeWorkspaceMultiplexer(workspaceId);
});
</script>
{#snippet workspaceHeader()}
@@ -1,21 +1,33 @@
import { error } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { LayoutLoad } from "./$types";
import type {
RepositoryListResponse,
WorkspaceResponse,
} from "$lib/workspace/sidebar/types";
import type { LayoutLoad } from "./$types";
export const load: LayoutLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
const [workspace, repositories] = await Promise.all([
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
loadJson<WorkspaceResponse>(
fetch,
workspaceApiPath(workspaceId, "/workspace"),
),
loadJson<RepositoryListResponse>(
fetch,
workspaceApiPath(workspaceId, "/repositories"),
),
]);
if (!workspace.data) {
error(404, {
message: workspace.error ?? `Workspace ${workspaceId} is unavailable`,
});
}
return {
workspace: workspace.data,
workspaceError: workspace.error,
workspaceError: null,
repositories: repositories.data,
repositoriesError: repositories.error,
};