web: add workspace ticket routes

This commit is contained in:
Keisuke Hirata 2026-07-23 02:44:20 +09:00
parent 681034db1e
commit 7dda898830
No known key found for this signature in database
28 changed files with 397 additions and 114 deletions

View File

@ -445,12 +445,14 @@
.runtime-card {
padding: 0;
}
.ticket-list,
.objective-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-4);
}
.ticket-row,
.objective-row {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
@ -463,27 +465,34 @@
text-decoration: none;
transition: background-color 140ms ease;
}
.ticket-row:hover,
.ticket-row:focus-visible,
.objective-row:hover,
.objective-row:focus-visible {
background: var(--interactive-hover);
}
.ticket-row.selected,
.objective-row.selected {
background: var(--interactive-selected);
}
.ticket-row.selected .ticket-title,
.objective-row.selected .objective-title {
color: var(--accent);
}
.ticket-main,
.objective-main {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.ticket-title-row,
.objective-title-row {
display: flex;
align-items: baseline;
gap: var(--space-2);
min-width: 0;
}
.ticket-title,
.objective-title {
min-width: 0;
overflow: hidden;
@ -500,12 +509,14 @@
letter-spacing: 0.05em;
text-transform: uppercase;
}
.ticket-summary,
.objective-summary {
margin: 0;
color: var(--text-muted);
font-size: 0.88rem;
line-height: 1.4;
}
.ticket-meta,
.objective-meta {
display: flex;
flex-wrap: wrap;
@ -518,10 +529,12 @@
white-space: nowrap;
}
@media (max-width: 900px) {
.ticket-row,
.objective-row {
grid-template-columns: 1fr;
gap: var(--space-2);
}
.ticket-meta,
.objective-meta {
justify-content: flex-start;
text-align: left;
@ -542,6 +555,9 @@
.runtime-heading span.warn {
color: var(--warning);
}
.detail-heading.compact {
margin-bottom: var(--space-2);
}
.detail-heading h3 {
margin: 0;
}
@ -549,6 +565,36 @@
color: var(--text-muted);
font-size: 0.8rem;
}
.ticket-detail-grid {
margin-bottom: var(--space-4);
}
.risk-flags {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.risk-flags span,
.warning-pill {
border-radius: 999px;
padding: 0.18rem 0.55rem;
background: var(--bg-subtle);
color: var(--warning);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.ticket-body pre {
overflow-x: auto;
margin: 0;
padding: var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: var(--text);
white-space: pre-wrap;
}
dl {
display: grid;
gap: var(--space-1);

View File

@ -38,7 +38,7 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor
);
});
Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped workspace data", async () => {
Deno.test("root layout bootstraps only the scoped workspace entry", async () => {
const layout = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url),
);
@ -47,24 +47,17 @@ Deno.test("unscoped layout bootstraps then redirects instead of loading unscoped
"unscoped layout may use only the workspace-id bootstrap endpoint",
);
assert(
layout.includes("throw redirect(307") && layout.includes("workspaceRoute("),
"unscoped layout should redirect to the scoped workspace route",
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",
);
const unscopedSettings = await Deno.readTextFile(
new URL("./../../../routes/settings/+page.svelte", import.meta.url),
);
assert(
unscopedSettings.includes("Redirecting to scoped settings") &&
!unscopedSettings.includes("fetch(") &&
!unscopedSettings.includes("settingsApiPath"),
"unscoped settings route should remain a thin redirect shim, not a data/control surface",
);
});
Deno.test("Skill API paths use workspace backend scoped endpoints", () => {

View File

@ -27,9 +27,13 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
);
assert(
workspacePage.includes("href={`/w/${workspaceId}/runtimes`}") &&
workspacePage.includes("href={`/w/${workspaceId}/workers`}"),
"top workspace page should link to Runtimes and Workers pages",
workspacePage.includes("ticketsHref") &&
workspacePage.includes("runtimeSettingsHref") &&
workspacePage.includes("workersHref") &&
workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") &&
workspacePage.includes("workspaceRoute(workspaceId, '/settings/runtimes')") &&
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
"top workspace page should link to Tickets, Runtime Inventory under Settings, and the Workers page",
);
assert(
!workspacePage.includes("workerConsoleHref") &&
@ -51,8 +55,66 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
);
assert(
!sidebar.includes("CompanionNavSection") &&
sidebar.includes("TicketsNavSection") &&
sidebar.includes("WorkersNavSection"),
"standalone Companion/Console navigation should not remain canonical",
"standalone Companion/Console navigation should not remain canonical and Tickets should be primary workspace navigation",
);
});
Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async () => {
const ticketsNav = await Deno.readTextFile(
new URL("../sidebar/TicketsNavSection.svelte", import.meta.url),
);
const ticketsLoad = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/tickets/+page.ts", import.meta.url),
);
const ticketsPage = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/tickets/+page.svelte", import.meta.url),
);
const ticketDetailLoad = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.ts",
import.meta.url,
),
);
const ticketDetailPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
import.meta.url,
),
);
assert(
ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") &&
ticketsNav.includes("Open Tickets"),
"Tickets sidebar section should link to the workspace Tickets surface",
);
assert(
ticketsLoad.includes('workspaceApiPath(params.workspaceId, "/tickets")') &&
ticketsPage.includes("This surface is read-only") &&
ticketsPage.includes("workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)"),
"Tickets list should read the workspace-scoped Ticket API and link to Ticket details",
);
assert(
ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") &&
ticketDetailPage.includes("event_count") &&
ticketDetailPage.includes("artifact_count") &&
ticketDetailPage.includes("<pre>{data.ticket.data.body"),
"Ticket detail should read one Ticket record and expose body plus metadata without mutation controls",
);
});
Deno.test("root layout does not keep legacy unscoped route compatibility", async () => {
const layoutLoad = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url),
);
assert(
!layoutLoad.includes("scopedCompatibilityRoute") &&
!layoutLoad.includes('pathname === "/runtimes"') &&
!layoutLoad.includes("return workspaceRoute(workspaceId, pathname)") &&
layoutLoad.includes("workspaceRoute(workspace.data.workspace_id)"),
"root layout should bootstrap the workspace entry only, not preserve legacy unscoped routes",
);
});
@ -188,49 +250,50 @@ Deno.test("Decodal source editor keeps imperative EditorView out of reactive sta
);
});
Deno.test("workspace Runtime management pages expose Runtimes and Runtime-owned workdirs", async () => {
Deno.test("workspace Runtime inventory lives under Settings admin routes", async () => {
const sidebar = await Deno.readTextFile(
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
);
const runtimesNav = await Deno.readTextFile(
new URL("../sidebar/RuntimesNavSection.svelte", import.meta.url),
const settingsModel = await Deno.readTextFile(
new URL("../settings/model.ts", import.meta.url),
);
const runtimesPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/+page.svelte",
"./../../../routes/w/[workspaceId]/settings/runtimes/+page.svelte",
import.meta.url,
),
);
const workdirsPage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.svelte",
"./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte",
import.meta.url,
),
);
const workdirsLoad = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workdirs/+page.ts",
"./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.ts",
import.meta.url,
),
);
assert(
sidebar.includes("RuntimesNavSection") &&
runtimesNav.includes("href={runtimesHref}") &&
runtimesNav.includes("/runtimes"),
"sidebar should expose Runtime management navigation",
!sidebar.includes("RuntimesNavSection") &&
settingsModel.includes('id: "runtime-inventory"') &&
settingsModel.includes('return `${SETTINGS_ROUTE}/runtimes`;'),
"Runtime inventory should be admin Settings navigation, not primary workspace sidebar navigation",
);
assert(
runtimesPage.includes("Runtime Inventory") &&
runtimesPage.includes("Open workdirs") &&
runtimesPage.includes("runtimes-table") &&
runtimesPage.includes("/workdirs"),
"Runtimes page should table Runtimes and link to each Runtime's workdirs",
runtimesPage.includes("/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs"),
"Settings Runtime Inventory page should table Runtimes and link to each Runtime's workdirs",
);
assert(
workdirsPage.includes("Workdirs") &&
workdirsPage.includes("Runtime Inventory") &&
workdirsPage.includes("workdirs-table") &&
workdirsLoad.includes("/working-directories"),
"Workdirs page should read Runtime-owned working-directory API while using workdir UI language",
"Runtime workdirs should remain backed by Runtime APIs without legacy Runtime route redirects",
);
});

View File

@ -6,6 +6,7 @@ export type Diagnostic = {
export type SettingsSectionId =
| "runtime-connections"
| "runtime-inventory"
| "profile-sources"
| "backend-config"
| "workspace-identity";
@ -85,6 +86,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
"Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.",
],
},
{
id: "runtime-inventory",
label: "Runtime Inventory",
status: "read-only",
summary:
"Inspect registered Runtime handles and their materialized workdirs from the admin settings surface instead of the normal workspace navigation.",
bullets: [
"Runtime state is operational Backend/Runtime context, not a workspace content object like Objectives or Repositories.",
"Workdir cleanup remains scoped to typed Runtime APIs and stays outside the primary workspace sidebar.",
"Console routes may still target a Runtime handle directly, but Runtime discovery belongs under Settings.",
],
},
{
id: "profile-sources",
label: "Profile Sources",
@ -145,6 +158,8 @@ export function settingsSectionHref(id: SettingsSectionId): string {
switch (id) {
case "runtime-connections":
return `${SETTINGS_ROUTE}/runtime-connections`;
case "runtime-inventory":
return `${SETTINGS_ROUTE}/runtimes`;
case "profile-sources":
return `${SETTINGS_ROUTE}/profiles`;
case "workspace-identity":

View File

@ -1,23 +0,0 @@
<script lang="ts">
type Props = {
currentPath?: string;
workspaceId: string;
};
let { currentPath = '/', workspaceId }: Props = $props();
let runtimesHref = $derived(workspaceId ? `/w/${workspaceId}/runtimes` : '/runtimes');
let active = $derived(currentPath === runtimesHref || currentPath.startsWith(`${runtimesHref}/`));
</script>
<section class="sidebar-section" aria-labelledby="runtimes-heading">
<div class="section-heading-row">
<h2 id="runtimes-heading">
<a
class="section-heading-link"
class:active
href={runtimesHref}
aria-current={active ? 'page' : undefined}
>runtimes</a>
</h2>
</div>
</section>

View File

@ -0,0 +1,22 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
type Props = {
currentPath?: string;
workspaceId: string;
};
let { currentPath = '/', workspaceId }: Props = $props();
let ticketsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/tickets') : '/');
</script>
<section class="nav-section">
<header class="section-header">
<span>Tickets</span>
</header>
<a class="objective-link" class:active={currentPath.startsWith(ticketsHref)} href={ticketsHref}>
<span class="item-title">Open Tickets</span>
<span class="item-meta">workspace tickets</span>
</a>
</section>

View File

@ -2,7 +2,7 @@
import { workspaceRoute } from '$lib/workspace/api/http';
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import RuntimesNavSection from './RuntimesNavSection.svelte';
import TicketsNavSection from './TicketsNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
import type { RepositoryListResponse, WorkspaceResponse } from './types';
@ -107,7 +107,7 @@
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} />
<RuntimesNavSection {currentPath} {workspaceId} />
<TicketsNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} />
</nav>
{/if}

View File

@ -320,6 +320,31 @@ export type TicketSummary = {
record_source?: string;
};
export type TicketListResponse = {
workspace_id: string;
limit: number;
items: TicketSummary[];
invalid_records: InvalidProjectRecord[];
record_authority: string;
};
export type TicketDetail = {
id: string;
title: string;
state: string;
priority?: string | null;
created_at?: string | null;
updated_at?: string | null;
queued_by?: string | null;
queued_at?: string | null;
risk_flags: string[];
body: string;
body_truncated: boolean;
event_count: number;
artifact_count: number;
record_source: string;
};
export type TicketKanbanColumn = {
state: string;
items: TicketSummary[];

View File

@ -12,10 +12,7 @@ export const load: LayoutLoad = async ({ fetch, params, url }) => {
if (!workspaceId) {
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
if (workspace.data) {
const scopedPath = workspaceRoute(
workspace.data.workspace_id,
url.pathname === "/" ? "" : url.pathname,
);
const scopedPath = workspaceRoute(workspace.data.workspace_id);
throw redirect(307, `${scopedPath}${url.search}`);
}
return {

View File

@ -1,6 +1,6 @@
<main class="workspace-panel-shell">
<section class="workspace-card">
<h1>Redirecting to scoped workspace…</h1>
<p class="section-note">This compatibility route bootstraps the current workspace id and redirects to the canonical <code>/w/&lt;workspace-id&gt;</code> route.</p>
<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>
</section>
</main>

View File

@ -1,6 +0,0 @@
<main class="objectives-page">
<section class="workspace-card">
<h1>Redirecting to scoped objectives…</h1>
<p class="section-note">Objectives are available at the canonical <code>/w/&lt;workspace-id&gt;/objectives</code> route.</p>
</section>
</main>

View File

@ -1,3 +0,0 @@
import type { PageLoad } from "./$types";
export const load: PageLoad = async () => ({});

View File

@ -1,6 +0,0 @@
<main class="objective-detail-page">
<section class="workspace-card">
<h1>Redirecting to scoped objective…</h1>
<p class="section-note">Objective detail routes are canonical under <code>/w/&lt;workspace-id&gt;/objectives/&lt;objective-id&gt;</code>.</p>
</section>
</main>

View File

@ -1,3 +0,0 @@
import type { PageLoad } from "./$types";
export const load: PageLoad = async () => ({});

View File

@ -1,6 +0,0 @@
<main class="repository-detail-page">
<section class="workspace-card">
<h1>Redirecting to scoped repository…</h1>
<p class="section-note">Repository routes are canonical under <code>/w/&lt;workspace-id&gt;/repositories/&lt;repository-id&gt;</code>.</p>
</section>
</main>

View File

@ -1,3 +0,0 @@
import type { PageLoad } from "./$types";
export const load: PageLoad = async () => ({});

View File

@ -1,6 +0,0 @@
<main class="worker-console-page">
<section class="workspace-card">
<h1>Redirecting to scoped Worker Console…</h1>
<p class="section-note">Worker Console routes are canonical under <code>/w/&lt;workspace-id&gt;/runtimes/&lt;runtime-id&gt;/workers/&lt;worker-id&gt;/console</code>.</p>
</section>
</main>

View File

@ -1,3 +0,0 @@
import type { PageLoad } from "./$types";
export const load: PageLoad = async () => ({});

View File

@ -1,6 +0,0 @@
<main class="settings-page">
<section class="workspace-card">
<h1>Redirecting to scoped settings…</h1>
<p class="section-note">Settings are available at the canonical <code>/w/&lt;workspace-id&gt;/settings</code> route.</p>
</section>
</main>

View File

@ -1,8 +1,12 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
let ticketsHref = $derived(workspaceRoute(workspaceId, '/tickets'));
let runtimeSettingsHref = $derived(workspaceRoute(workspaceId, '/settings/runtimes'));
let workersHref = $derived(workspaceRoute(workspaceId, '/workers'));
</script>
<svelte:head>
@ -39,12 +43,17 @@
</section>
<section class="workspace-actions" aria-label="Workspace sections">
<a class="workspace-action-card" href={`/w/${workspaceId}/runtimes`}>
<span>Runtimes</span>
<strong>Manage runtimes and workdirs</strong>
<a class="workspace-action-card" href={ticketsHref}>
<span>Tickets</span>
<strong>Browse workspace tickets</strong>
<small>Read typed Ticket records</small>
</a>
<a class="workspace-action-card" href={runtimeSettingsHref}>
<span>Runtime Inventory</span>
<strong>Open admin runtime inventory</strong>
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
</a>
<a class="workspace-action-card" href={`/w/${workspaceId}/workers`}>
<a class="workspace-action-card" href={workersHref}>
<span>Workers</span>
<strong>Open worker list</strong>
<small>Inspect status and attach to consoles</small>

View File

@ -10,15 +10,15 @@
</script>
<svelte:head>
<title>Runtimes · Yoi Workspace</title>
<meta name="description" content="Workspace Runtimes" />
<title>Runtime Inventory · Settings · Yoi Workspace</title>
<meta name="description" content="Workspace Runtime inventory" />
</svelte:head>
<section class="runtimes-page" aria-labelledby="runtimes-heading">
<header class="page-header-row">
<div>
<h1 id="runtimes-heading">Runtimes</h1>
<p>Execution backends available to this workspace.</p>
<h1 id="runtimes-heading">Runtime Inventory</h1>
<p>Admin view of execution backends available to this workspace.</p>
</div>
</header>
@ -53,7 +53,7 @@
<td>{runtimePlatform(runtime)}</td>
<td>{runtime.capabilities.max_workers} workers</td>
<td>
<a class="inline-link" href={`/w/${data.workspaceId}/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
<a class="inline-link" href={`/w/${data.workspaceId}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs`}>
Open workdirs
</a>
</td>

View File

@ -105,7 +105,7 @@
<section class="workdirs-page" aria-labelledby="workdirs-heading">
<header class="page-header-row">
<div>
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/runtimes`}>Runtimes</a> / {runtimeLabel}</p>
<p class="breadcrumb"><a href={`/w/${data.workspaceId}/settings/runtimes`}>Runtime Inventory</a> / {runtimeLabel}</p>
<h1 id="workdirs-heading">Workdirs</h1>
<p>Workdirs owned by <code>{data.runtimeId}</code>.</p>
{#if data.cleanupPlanError}<p class="section-state error">{data.cleanupPlanError}</p>{/if}

View File

@ -0,0 +1,64 @@
<script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<svelte:head>
<title>Tickets · Yoi Workspace</title>
<meta name="description" content="Workspace Tickets" />
</svelte:head>
<section class="card">
<div class="detail-heading">
<div>
<p class="eyebrow">Workspace records</p>
<h2>Tickets</h2>
</div>
{#if data.tickets.data}
<span>{data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'}</span>
{/if}
</div>
<p class="section-note">
Tickets are read from the typed Ticket backend. This surface is read-only; creation and queue operations remain outside the browser UI for now.
</p>
{#if data.tickets.data}
{#if data.tickets.data.items.length === 0}
<p>No Ticket records are present.</p>
{:else}
<div class="ticket-list" aria-label="Workspace Tickets">
{#each data.tickets.data.items as ticket (ticket.id)}
<a class="ticket-row" href={workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)}>
<div class="ticket-main">
<div class="ticket-title-row">
<strong class="ticket-title">{ticket.title}</strong>
<span class="state-pill">{ticket.state}</span>
</div>
<p class="ticket-summary">
{ticket.priority ? `${ticket.priority} priority` : 'priority unspecified'} · {ticket.record_source ?? 'ticket backend'}
</p>
</div>
<div class="ticket-meta" aria-label="Ticket metadata">
<span>Updated {ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'}</span>
{#if ticket.queued_at}
<span>Queued {formatDate(ticket.queued_at)}</span>
{/if}
<code>{ticket.id}</code>
</div>
</a>
{/each}
</div>
{/if}
{#if data.tickets.data.invalid_records.length > 0}
<p class="error">{data.tickets.data.invalid_records.length} invalid Ticket record(s) hidden.</p>
{/if}
{:else if data.tickets.error}
<p class="error">{data.tickets.error}</p>
{:else}
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets</code></p>
{/if}
</section>

View File

@ -0,0 +1,15 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const tickets = await loadJson<TicketListResponse>(
fetch,
workspaceApiPath(params.workspaceId, "/tickets"),
);
return {
workspaceId: params.workspaceId,
tickets,
};
}) satisfies PageLoad;

View File

@ -0,0 +1,82 @@
<script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<svelte:head>
<title>{data.ticket.data?.title ?? data.ticketId} · Tickets · Yoi Workspace</title>
<meta name="description" content="Workspace Ticket detail" />
</svelte:head>
<section class="card">
<p class="breadcrumb"><a href={workspaceRoute(data.workspaceId, '/tickets')}>Tickets</a> / {data.ticketId}</p>
{#if data.ticket.data}
<div class="detail-heading">
<div>
<p class="eyebrow">{data.ticket.data.id}</p>
<h2>{data.ticket.data.title}</h2>
</div>
<span class="state-pill">{data.ticket.data.state}</span>
</div>
<dl class="ticket-detail-grid">
<div>
<dt>Priority</dt>
<dd>{data.ticket.data.priority ?? 'unspecified'}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{data.ticket.data.updated_at ? formatDate(data.ticket.data.updated_at) : 'unknown'}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{data.ticket.data.created_at ? formatDate(data.ticket.data.created_at) : 'unknown'}</dd>
</div>
<div>
<dt>Events</dt>
<dd>{data.ticket.data.event_count}</dd>
</div>
<div>
<dt>Artifacts</dt>
<dd>{data.ticket.data.artifact_count}</dd>
</div>
<div>
<dt>Source</dt>
<dd>{data.ticket.data.record_source}</dd>
</div>
{#if data.ticket.data.queued_at || data.ticket.data.queued_by}
<div>
<dt>Queued</dt>
<dd>
{data.ticket.data.queued_at ? formatDate(data.ticket.data.queued_at) : 'queued'}{data.ticket.data.queued_by ? ` by ${data.ticket.data.queued_by}` : ''}
</dd>
</div>
{/if}
</dl>
{#if data.ticket.data.risk_flags.length > 0}
<div class="risk-flags" aria-label="Risk flags">
{#each data.ticket.data.risk_flags as flag}
<span>{flag}</span>
{/each}
</div>
{/if}
<section class="ticket-body" aria-labelledby="ticket-body-heading">
<div class="detail-heading compact">
<h3 id="ticket-body-heading">Body</h3>
{#if data.ticket.data.body_truncated}
<span class="warning-pill">truncated</span>
{/if}
</div>
<pre>{data.ticket.data.body || 'No body text is available.'}</pre>
</section>
{:else if data.ticket.error}
<p class="error">{data.ticket.error}</p>
{:else}
<p>Waiting for <code>/api/w/{data.workspaceId}/tickets/{data.ticketId}</code></p>
{/if}
</section>

View File

@ -0,0 +1,17 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { TicketDetail } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const ticketId = params.ticketId;
const ticket = await loadJson<TicketDetail>(
fetch,
workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(ticketId)}`),
);
return {
workspaceId: params.workspaceId,
ticketId,
ticket,
};
}) satisfies PageLoad;