merge: integrate ticket panel workflow

This commit is contained in:
2026-07-31 02:22:49 +09:00
28 changed files with 2433 additions and 1156 deletions
@@ -3,6 +3,7 @@
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
import '$lib/workspace/styles/workspace-pages.css';
import '$lib/workspace/styles/tickets.css';
import '$lib/workspace/styles/workers.css';
import type { LayoutProps } from './$types';
@@ -1,6 +1,5 @@
<script lang="ts">
import { formatDate } from '$lib/workspace/api/http';
import RepositoryTicketKanban from '$lib/workspace/pages/RepositoryTicketKanban.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -92,14 +91,3 @@
<p>Loading repository commits…</p>
{/if}
</section>
<section class="card repository-tickets-card">
<h2>Repository Tickets</h2>
{#if data.repositoryTickets}
<RepositoryTicketKanban tickets={data.repositoryTickets} />
{:else if data.repositoryTicketsError}
<p class="error">{data.repositoryTicketsError}</p>
{:else}
<p>Loading repository tickets…</p>
{/if}
</section>
@@ -2,14 +2,13 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type {
RepositoryDetailResponse,
RepositoryLogResponse,
RepositoryTicketsResponse,
} from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const repositoryId = params.repositoryId;
const [repository, log, tickets] = await Promise.all([
const [repository, log] = await Promise.all([
loadJson<RepositoryDetailResponse>(
fetch,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}`),
@@ -18,10 +17,6 @@ export const load: PageLoad = async ({ fetch, params }) => {
fetch,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/log`),
),
loadJson<RepositoryTicketsResponse>(
fetch,
apiPath(`/repositories/${encodeURIComponent(repositoryId)}/tickets`),
),
]);
return {
@@ -30,7 +25,5 @@ export const load: PageLoad = async ({ fetch, params }) => {
repositoryError: repository.error,
repositoryLog: log.data,
repositoryLogError: log.error,
repositoryTickets: tickets.data,
repositoryTicketsError: tickets.error,
};
};
@@ -1,392 +1,76 @@
<script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import type { TicketSummary } from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types';
import { untrack } from "svelte";
import type { ApiResult } from "$lib/workspace/api/http";
import { ticketLanes } from "$lib/workspace/tickets/ticket-panel";
import type {
TicketListResponse,
TicketSummary,
} from "$lib/workspace/sidebar/types";
type SortKey = 'panel' | 'title' | 'state' | 'priority' | 'updated_at' | 'queued_at' | 'id';
type SortDirection = 'asc' | 'desc';
const { data } = $props<{
data: {
workspaceId: string;
tickets: ApiResult<TicketListResponse>;
};
}>();
let { data }: PageProps = $props();
const initialTickets = untrack(() => data.tickets.data?.items ?? []);
let tickets = $state<TicketSummary[]>(initialTickets);
let lanes = $derived(ticketLanes(tickets));
let query = $state('');
let visibilityFilter = $state<'open' | 'closed' | 'all'>('open');
let stateFilter = $state('all');
let priorityFilter = $state('all');
let queuedFilter = $state('all');
let sortKey = $state<SortKey>('panel');
let sortDirection = $state<SortDirection>('asc');
const tickets = $derived(data.tickets.data?.items ?? []);
const states = $derived(uniqueValues(tickets.map((ticket) => ticket.state)));
const priorities = $derived(uniqueValues(tickets.map((ticket) => ticket.priority).filter(isPresent)));
const filteredTickets = $derived(filterTickets(tickets));
const visibleTickets = $derived(sortTickets(filteredTickets));
function isPresent(value: string | null | undefined): value is string {
return Boolean(value && value.trim());
}
function uniqueValues(values: string[]): string[] {
return [...new Set(values.filter((value) => value.trim()))].sort((left, right) =>
left.localeCompare(right),
);
}
function filterTickets(items: TicketSummary[]): TicketSummary[] {
const needle = query.trim().toLowerCase();
return items.filter((ticket) => {
if (visibilityFilter === 'open' && ticket.state === 'closed') {
return false;
}
if (visibilityFilter === 'closed' && ticket.state !== 'closed') {
return false;
}
if (stateFilter !== 'all' && ticket.state !== stateFilter) {
return false;
}
if (priorityFilter !== 'all' && (ticket.priority ?? '') !== priorityFilter) {
return false;
}
if (queuedFilter === 'queued' && !ticket.queued_at) {
return false;
}
if (queuedFilter === 'unqueued' && ticket.queued_at) {
return false;
}
if (!needle) {
return true;
}
return [ticket.id, ticket.title, ticket.state, ticket.priority, ticket.queued_by, ticket.record_source]
.filter(isPresent)
.some((value) => value.toLowerCase().includes(needle));
});
}
function sortTickets(items: TicketSummary[]): TicketSummary[] {
return [...items].sort((left, right) => {
const result = compareTicketValues(left, right, sortKey);
return sortDirection === 'asc' ? result : -result;
});
}
function compareTicketValues(left: TicketSummary, right: TicketSummary, key: SortKey): number {
if (key === 'panel') {
return comparePanelOrder(left, right);
}
if (key === 'updated_at' || key === 'queued_at') {
return compareDate(left[key], right[key]);
}
return compareText(ticketValue(left, key), ticketValue(right, key));
}
function comparePanelOrder(left: TicketSummary, right: TicketSummary): number {
return compareNumber(panelActionPriority(left), panelActionPriority(right))
|| compareDate(right.updated_at, left.updated_at)
|| compareText(left.title, right.title);
}
function panelActionPriority(ticket: TicketSummary): number {
if (ticket.workspace_action_priority) {
if (ticket.workspace_action_priority === 'ready_for_queue') return 0;
if (ticket.workspace_action_priority === 'active_work') return 1;
if (ticket.workspace_action_priority === 'background') return 2;
}
if (ticket.state === 'ready') return 0;
if (ticket.state === 'queued' || ticket.state === 'inprogress') return 1;
return 2;
}
function compareNumber(left: number, right: number): number {
return left - right;
}
function ticketValue(ticket: TicketSummary, key: SortKey): string | null | undefined {
if (key === 'id') return ticket.id;
if (key === 'title') return ticket.title;
if (key === 'state') return ticket.state;
if (key === 'priority') return ticket.priority;
return null;
}
function compareText(left: string | null | undefined, right: string | null | undefined): number {
const leftText = left?.trim() ?? '';
const rightText = right?.trim() ?? '';
if (!leftText && rightText) return 1;
if (leftText && !rightText) return -1;
return leftText.localeCompare(rightText);
}
function compareDate(left: string | null | undefined, right: string | null | undefined): number {
const leftTime = left ? Date.parse(left) : Number.NEGATIVE_INFINITY;
const rightTime = right ? Date.parse(right) : Number.NEGATIVE_INFINITY;
return leftTime - rightTime;
}
function toggleSort(key: SortKey) {
if (sortKey === key) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
return;
}
sortKey = key;
sortDirection = key === 'updated_at' || key === 'queued_at' ? 'desc' : 'asc';
}
function sortLabel(key: SortKey): string {
if (sortKey !== key) return '';
return sortDirection === 'asc' ? '↑' : '↓';
}
function resetFilters() {
query = '';
visibilityFilter = 'open';
stateFilter = 'all';
priorityFilter = 'all';
queuedFilter = 'all';
sortKey = 'panel';
sortDirection = 'asc';
function prettyDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
}
</script>
<svelte:head>
<title>Tickets · Yoi Workspace</title>
<meta name="description" content="Workspace Tickets" />
</svelte:head>
<svelte:head><title>Tickets · Yoi</title></svelte:head>
<section class="card ticket-database-card">
<div class="detail-heading">
<div class="workspace-page ticket-panel-page">
<header class="workspace-page-header ticket-panel-header">
<div>
<p class="eyebrow">Workspace records</p>
<h2>Tickets</h2>
<p class="workspace-eyebrow">Delivery</p>
<h1>Tickets</h1>
<p class="workspace-page-lede">
Plan, route, review, and close work without leaving the workspace.
</p>
</div>
{#if data.tickets.data}
<span>{visibleTickets.length} / {data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'}</span>
{/if}
</div>
<div class="ticket-panel-summary" aria-label="Ticket summary">
<strong>{tickets.length}</strong>
<span>tickets</span>
</div>
</header>
<p class="section-note">
Tickets are read from the typed Ticket backend. This read-only table supports Notion-style filtering and sorting for browsing imported workspace Tickets.
</p>
<section class="ticket-kanban" aria-label="Ticket workflow board">
{#each lanes as lane (lane.id)}
<section class="ticket-lane" data-state={lane.id}>
<header class="ticket-lane-header">
<div>
<span class="ticket-state-dot"></span>
<h2>{lane.label}</h2>
</div>
<span class="ticket-lane-count">{lane.tickets.length}</span>
</header>
{#if data.tickets.data}
{#if data.tickets.data.items.length === 0}
<p>No Ticket records are present.</p>
{:else}
<div class="ticket-database-toolbar" aria-label="Ticket table controls">
<label class="ticket-filter ticket-search">
<span>Search</span>
<input bind:value={query} type="search" placeholder="Title, id, state, source…" />
</label>
<label class="ticket-filter">
<span>Visibility</span>
<select bind:value={visibilityFilter}>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="all">All</option>
</select>
</label>
<label class="ticket-filter">
<span>State</span>
<select bind:value={stateFilter}>
<option value="all">All states</option>
{#each states as state}
<option value={state}>{state}</option>
{/each}
</select>
</label>
<label class="ticket-filter">
<span>Priority</span>
<select bind:value={priorityFilter}>
<option value="all">All priorities</option>
{#each priorities as priority}
<option value={priority}>{priority}</option>
{/each}
</select>
</label>
<label class="ticket-filter">
<span>Queue</span>
<select bind:value={queuedFilter}>
<option value="all">All</option>
<option value="queued">Queued</option>
<option value="unqueued">Unqueued</option>
</select>
</label>
<button class="secondary-button" type="button" onclick={() => toggleSort('panel')}>Panel order {sortLabel('panel')}</button>
<button class="secondary-button" type="button" onclick={resetFilters}>Reset</button>
</div>
<div class="ticket-table-wrap" aria-label="Workspace Tickets table">
<table class="ticket-table">
<thead>
<tr>
<th><button type="button" onclick={() => toggleSort('title')}>Title {sortLabel('title')}</button></th>
<th><button type="button" onclick={() => toggleSort('state')}>State {sortLabel('state')}</button></th>
<th><button type="button" onclick={() => toggleSort('priority')}>Priority {sortLabel('priority')}</button></th>
<th><button type="button" onclick={() => toggleSort('updated_at')}>Updated {sortLabel('updated_at')}</button></th>
<th><button type="button" onclick={() => toggleSort('queued_at')}>Queued {sortLabel('queued_at')}</button></th>
<th><button type="button" onclick={() => toggleSort('id')}>ID {sortLabel('id')}</button></th>
</tr>
</thead>
<tbody>
{#each visibleTickets as ticket (ticket.id)}
<tr>
<td class="ticket-title-cell">
<a href={workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)}>{ticket.title}</a>
<span>{ticket.record_source ?? 'ticket backend'}</span>
</td>
<td><span class="state-pill">{ticket.state}</span></td>
<td>{ticket.priority || '—'}</td>
<td>{ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'}</td>
<td>
{#if ticket.queued_at}
<span>{formatDate(ticket.queued_at)}</span>
{#if ticket.queued_by}
<small>{ticket.queued_by}</small>
{/if}
{:else}
<span class="muted"></span>
{/if}
</td>
<td><code>{ticket.id}</code></td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if visibleTickets.length === 0}
<p class="section-note">No tickets match the current filters.</p>
{/if}
{/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>
<style>
.ticket-database-card {
overflow: hidden;
}
.ticket-database-toolbar {
display: grid;
grid-template-columns: minmax(16rem, 1.8fr) repeat(4, minmax(9rem, 1fr)) auto auto;
gap: 0.75rem;
align-items: end;
margin: 1rem 0;
}
.ticket-filter {
display: grid;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.ticket-filter input,
.ticket-filter select {
min-height: 2.35rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--bg-raised);
color: inherit;
font: inherit;
font-weight: 500;
letter-spacing: normal;
padding: 0 0.7rem;
text-transform: none;
}
.secondary-button {
min-height: 2.35rem;
border: 1px solid var(--line);
border-radius: 0.6rem;
background: var(--bg-raised);
color: inherit;
cursor: pointer;
font: inherit;
font-weight: 600;
padding: 0 0.9rem;
}
.ticket-table-wrap {
border: 1px solid var(--line);
border-radius: 0.8rem;
overflow: auto;
}
.ticket-table {
width: 100%;
min-width: 58rem;
border-collapse: collapse;
background: var(--bg-raised);
color: var(--text);
font-size: 0.9rem;
}
.ticket-table th,
.ticket-table td {
border-bottom: 1px solid var(--line);
padding: 0.72rem 0.8rem;
text-align: left;
vertical-align: top;
}
.ticket-table th {
background: var(--bg-subtle);
color: var(--text-muted);
font-size: 0.76rem;
letter-spacing: 0.04em;
position: sticky;
text-transform: uppercase;
top: 0;
z-index: 1;
}
.ticket-table th button {
all: unset;
cursor: pointer;
}
.ticket-table tbody tr:hover {
background: var(--interactive-hover);
}
.ticket-title-cell {
min-width: 22rem;
}
.ticket-title-cell a {
color: inherit;
display: block;
font-weight: 700;
text-decoration: none;
}
.ticket-title-cell a:hover {
text-decoration: underline;
}
.ticket-title-cell span,
.ticket-table small,
.muted {
color: var(--text-muted);
display: block;
font-size: 0.78rem;
margin-top: 0.2rem;
}
@media (max-width: 900px) {
.ticket-database-toolbar {
grid-template-columns: 1fr;
}
}
</style>
<div class="ticket-lane-cards">
{#each lane.tickets as ticket (ticket.id)}
<a
class="ticket-card"
href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(ticket.id)}`}
>
<span class="ticket-card-id">{ticket.id}</span>
<strong>{ticket.title}</strong>
<div class="ticket-card-meta">
<span>{ticket.state} · {ticket.priority}</span>
<time>{prettyDate(ticket.updated_at)}</time>
</div>
</a>
{:else}
<div class="ticket-lane-empty">No tickets</div>
{/each}
</div>
</section>
{/each}
</section>
</div>
@@ -1,82 +1,362 @@
<script lang="ts">
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
import type { PageProps } from './$types';
import { untrack } from "svelte";
import RichMarkdown from "$lib/workspace/console/RichMarkdown.svelte";
import {
workspaceApiJsonWithBody,
workspaceApiPath,
} from "$lib/workspace/api/http";
import {
relationLabel,
TICKET_STATES,
ticketWorkerLaunchHref,
} from "$lib/workspace/tickets/ticket-panel";
import type { ApiResult } from "$lib/workspace/api/http";
import type {
RepositoryListResponse,
TicketDetail,
} from "$lib/workspace/sidebar/types";
let { data }: PageProps = $props();
const { data } = $props<{
data: {
workspaceId: string;
ticketId: string;
ticket: ApiResult<TicketDetail>;
repositories: ApiResult<RepositoryListResponse>;
};
}>();
const initialData = untrack(() => data);
const loadedTicket = initialData.ticket.data;
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
const loadedRepositories = initialData.repositories.data;
let ticket = $state<TicketDetail>(loadedTicket);
let editing = $state(false);
let editTitle = $state(loadedTicket.title);
let editBody = $state(loadedTicket.body);
let repositoryId = $state(loadedTicket.repository_id ?? "");
let refSelector = $state(loadedTicket.ref_selector ?? "");
let nextState = $state(loadedTicket.state);
let transitionReason = $state("");
let threadRole = $state("comment");
let threadBody = $state("");
let reviewResult = $state("approve");
let reviewBody = $state("");
let resolution = $state("");
let busy = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
const ticketPath = $derived(
workspaceApiPath(
data.workspaceId,
`/tickets/${encodeURIComponent(data.ticketId)}`,
),
);
function applyTicket(updatedTicket: TicketDetail): void {
ticket = updatedTicket;
editTitle = ticket.title;
editBody = ticket.body;
repositoryId = ticket.repository_id ?? "";
refSelector = ticket.ref_selector ?? "";
nextState = ticket.state;
}
async function mutate(
action: string,
suffix: string,
body?: Record<string, unknown>,
method = "POST",
): Promise<boolean> {
if (busy) return false;
busy = action;
errorMessage = null;
try {
const path = `${ticketPath}${suffix}`;
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
method,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
applyTicket(response);
return true;
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
return false;
} finally {
busy = null;
}
}
async function saveEdit(event: SubmitEvent) {
event.preventDefault();
if (
await mutate("edit", "", {
title: editTitle.trim(),
body: editBody,
}, "PATCH")
) editing = false;
}
async function saveTarget(event: SubmitEvent) {
event.preventDefault();
await mutate("target", "", {
target: repositoryId
? {
action: "set",
repository_id: repositoryId,
ref_selector: refSelector.trim() || null,
}
: { action: "clear" },
}, "PATCH");
}
async function transition(event: SubmitEvent) {
event.preventDefault();
if (
await mutate("state", "/state", {
state: nextState,
reason: transitionReason.trim() || null,
})
) transitionReason = "";
}
async function appendThread(event: SubmitEvent) {
event.preventDefault();
if (!threadBody.trim()) return;
if (
await mutate("thread", "/thread", {
role: threadRole,
body: threadBody.trim(),
})
) threadBody = "";
}
async function review(event: SubmitEvent) {
event.preventDefault();
if (!reviewBody.trim()) return;
if (
await mutate("review", "/review", {
result: reviewResult,
body: reviewBody.trim(),
})
) reviewBody = "";
}
async function closeTicket(event: SubmitEvent) {
event.preventDefault();
if (!resolution.trim()) return;
if (
await mutate("close", "/close", { resolution: resolution.trim() })
) resolution = "";
}
function eventTitle(kind: string): string {
return relationLabel(kind);
}
function prettyDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
</script>
<svelte:head>
<title>{data.ticket.data?.title ?? data.ticketId} · Tickets · Yoi Workspace</title>
<meta name="description" content="Workspace Ticket detail" />
</svelte:head>
<svelte:head><title>{ticket.title} · Yoi</title></svelte:head>
<section class="card">
<p class="breadcrumb"><a href={workspaceRoute(data.workspaceId, '/tickets')}>Tickets</a> / {data.ticketId}</p>
<div class="workspace-page ticket-detail-page">
<a class="workspace-back-link" href={`/w/${encodeURIComponent(data.workspaceId)}/tickets`}>
← Ticket board
</a>
{#if data.ticket.data}
<div class="detail-heading">
<div>
<p class="eyebrow">{data.ticket.data.id}</p>
<h2>{data.ticket.data.title}</h2>
<header class="ticket-detail-header">
<div>
<div class="ticket-detail-kicker">
<span class="workspace-status-pill" data-status={ticket.state}>{ticket.state}</span>
<code>{ticket.id}</code>
</div>
<span class="state-pill">{data.ticket.data.state}</span>
<h1>{ticket.title}</h1>
<p>Updated {prettyDate(ticket.updated_at)}</p>
</div>
<button class="workspace-secondary-button" type="button" onclick={() => editing = !editing}>
{editing ? "Cancel edit" : "Edit ticket"}
</button>
</header>
<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 errorMessage}
<div class="workspace-callout is-error" role="alert">{errorMessage}</div>
{/if}
</section>
{#if editing}
<form class="ticket-editor" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required /></label>
<label>Body<textarea bind:value={editBody} rows="12"></textarea></label>
<button class="workspace-primary-button" type="submit" disabled={busy === "edit" || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save changes"}
</button>
</form>
{/if}
<div class="ticket-detail-grid">
<main class="ticket-detail-main">
<section class="ticket-detail-section">
<div class="ticket-section-heading"><h2>Intent</h2></div>
{#if ticket.body}
<RichMarkdown text={ticket.body} />
{:else}
<p class="workspace-empty-copy">No body has been recorded.</p>
{/if}
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Relations</h2>
<span>{ticket.relations.outgoing.length + ticket.relations.incoming.length}</span>
</div>
{#if ticket.relations.blockers.length > 0}
<div class="ticket-blocker-list">
{#each ticket.relations.blockers as blocker}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_ticket)}`}>
<strong>Blocked by {blocker.blocking_ticket}</strong>
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
</a>
{/each}
</div>
{/if}
<div class="ticket-relations-list">
{#each ticket.relations.outgoing as relation}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target)}`}>
<span>{relationLabel(relation.kind)}</span>
<strong>{relation.target}</strong>
{#if relation.note}<small>{relation.note}</small>{/if}
</a>
{/each}
{#each ticket.relations.incoming as relation}
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_ticket)}`}>
<span>{relationLabel(relation.inverse_kind)}</span>
<strong>{relation.source_ticket}</strong>
{#if relation.note}<small>{relation.note}</small>{/if}
</a>
{/each}
{#if ticket.relations.outgoing.length === 0 && ticket.relations.incoming.length === 0}
<p class="workspace-empty-copy">No Ticket relations.</p>
{/if}
</div>
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Timeline</h2><span>{ticket.event_count}</span>
</div>
<div class="ticket-timeline">
{#each ticket.events as event (event.sequence)}
<article>
<div class="ticket-timeline-marker"></div>
<div>
<header>
<strong>{event.heading ?? eventTitle(event.kind)}</strong>
<time>{prettyDate(event.at)}</time>
</header>
{#if event.author}<p class="ticket-event-author">{event.author}</p>{/if}
{#if event.from || event.to}<p>{event.from ?? "—"}{event.to ?? "—"}</p>{/if}
{#if event.reason}<p>{event.reason}</p>{/if}
{#if event.body}<RichMarkdown text={event.body} />{/if}
</div>
</article>
{:else}
<p class="workspace-empty-copy">No timeline events.</p>
{/each}
</div>
</section>
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card ticket-worker-card">
<header><h2>Start a Worker</h2><span>Ticket role</span></header>
<p class="ticket-assignment-line">
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
</p>
<p>The common launch flow carries a short canonical Ticket message and the target below.</p>
<div class="ticket-role-actions">
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "reviewer")}>Reviewer</a>
</div>
</section>
<section class="ticket-control-card">
<header><h2>Repository target</h2></header>
<form class="ticket-control-form" onsubmit={saveTarget}>
<label>Repository
<select bind:value={repositoryId}>
<option value="">Not assigned</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.id}>{repository.display_name}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={refSelector} placeholder="branch, tag, or revision" /></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "target"}>
{busy === "target" ? "Saving…" : "Save target"}
</button>
</form>
</section>
<section class="ticket-control-card">
<header><h2>Workflow</h2></header>
<form class="ticket-control-form" onsubmit={transition}>
<label>State
<select bind:value={nextState}>
{#each TICKET_STATES as state}<option value={state}>{state}</option>{/each}
</select>
</label>
<label>Reason<input bind:value={transitionReason} placeholder="Optional decision context" /></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "state" || nextState === ticket.state}>
Apply state
</button>
</form>
{#if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue"} onclick={() => mutate("queue", "/queue", {})}>
{busy === "queue" ? "Queueing…" : "Queue ticket"}
</button>
{/if}
</section>
<details class="ticket-control-card">
<summary>Append timeline event</summary>
<form class="ticket-control-form" onsubmit={appendThread}>
<label>Role<select bind:value={threadRole}>
<option value="comment">Comment</option>
<option value="plan">Plan</option>
<option value="decision">Decision</option>
<option value="implementation_report">Implementation report</option>
</select></label>
<label>Body<textarea bind:value={threadBody} rows="5" required></textarea></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "thread" || !threadBody.trim()}>Append event</button>
</form>
</details>
<details class="ticket-control-card">
<summary>Record review</summary>
<form class="ticket-control-form" onsubmit={review}>
<label>Result<select bind:value={reviewResult}>
<option value="approve">Approve</option>
<option value="request_changes">Request changes</option>
</select></label>
<label>Review body<textarea bind:value={reviewBody} rows="5" required></textarea></label>
<button class="workspace-secondary-button" type="submit" disabled={busy === "review" || !reviewBody.trim()}>Record review</button>
</form>
</details>
{#if ticket.state !== "closed"}
<details class="ticket-control-card ticket-close-card">
<summary>Close ticket</summary>
<form class="ticket-control-form" onsubmit={closeTicket}>
<label>Resolution<textarea bind:value={resolution} rows="5" required></textarea></label>
<button class="workspace-danger-button" type="submit" disabled={busy === "close" || !resolution.trim()}>Close ticket</button>
</form>
</details>
{:else if ticket.resolution}
<section class="ticket-control-card"><header><h2>Resolution</h2></header><RichMarkdown text={ticket.resolution} /></section>
{/if}
</aside>
</div>
</div>
@@ -1,20 +1,29 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { TicketDetail } from "$lib/workspace/sidebar/types";
import type {
RepositoryListResponse,
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)}`,
const [ticket, repositories] = await Promise.all([
loadJson<TicketDetail>(
fetch,
workspaceApiPath(
params.workspaceId,
`/tickets/${encodeURIComponent(params.ticketId)}`,
),
),
);
loadJson<RepositoryListResponse>(
fetch,
workspaceApiPath(params.workspaceId, "/repositories"),
),
]);
return {
workspaceId: params.workspaceId,
ticketId,
ticketId: params.ticketId,
ticket,
repositories,
};
}) satisfies PageLoad;
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { workspaceApiPath } from '$lib/workspace/api/http';
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace/sidebar/worker-launch';
import type {
@@ -23,6 +24,7 @@
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspaceId);
const ticketContext = untrack(() => data.ticketContext);
const NEW_WORKING_DIRECTORY_VALUE = '__new_working_directory__';
@@ -31,13 +33,23 @@
let optionsError = $state<string | null>(null);
let submitting = $state(false);
let submitError = $state<DisplayError | null>(null);
let displayName = $state('Worker');
let displayName = $state(
ticketContext
? `${ticketContext.ticketTitle} · ${ticketContext.ticketRole || 'Worker'}`
: 'Worker',
);
let runtimeId = $state('');
let profile = $state('');
let initialText = $state('');
let profile = $state(
ticketContext
? ticketContext.ticketRole === 'reviewer'
? 'builtin:reviewer'
: 'builtin:coder'
: '',
);
let initialText = $state(ticketContext?.initialInput ?? '');
let workingDirectoryId = $state('');
let workingDirectoryRepositoryId = $state('');
let workingDirectorySelector = $state('HEAD');
let workingDirectoryRepositoryId = $state(ticketContext?.repositoryId ?? '');
let workingDirectorySelector = $state(ticketContext?.refSelector ?? 'HEAD');
let relativeCwd = $state('');
let creatingWorkingDirectory = $state(false);
let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE);
@@ -107,7 +119,8 @@
runtimeId = form.runtime_id;
displayName = form.display_name;
profile = form.profile;
workingDirectoryId = form.working_directory_id;
workingDirectoryId = form.working_directory_id ||
(ticketContext?.repositoryId ? NEW_WORKING_DIRECTORY_VALUE : '');
workingDirectoryRepositoryId = form.working_directory_repository_id;
workingDirectorySelector = form.working_directory_selector;
relativeCwd = form.relative_cwd;
@@ -254,6 +267,17 @@
<a class="secondary-link" href={`/w/${workspaceId}`}>Back to workspace</a>
</header>
{#if ticketContext}
<aside class="worker-ticket-context">
<div>
<span>Ticket {ticketContext.ticketRole || 'Worker'}</span>
<strong>{ticketContext.ticketTitle}</strong>
<code>{ticketContext.ticketId}</code>
</div>
<a href={`/w/${workspaceId}/tickets/${encodeURIComponent(ticketContext.ticketId)}`}>View ticket</a>
</aside>
{/if}
{#if loading}
<p class="section-state">Loading launch options…</p>
{:else if optionsError}
@@ -1,5 +1,19 @@
export function load({ params }: { params: { workspaceId: string } }) {
export function load(
{ params, url }: { params: { workspaceId: string }; url: URL },
) {
const ticketId = url.searchParams.get("ticketId") ?? "";
const ticketRole = url.searchParams.get("ticketRole") ?? "";
return {
workspaceId: params.workspaceId,
ticketContext: ticketId
? {
ticketId,
ticketTitle: url.searchParams.get("ticketTitle") ?? ticketId,
ticketRole,
initialInput: url.searchParams.get("initialInput") ?? "",
repositoryId: url.searchParams.get("repositoryId") ?? "",
refSelector: url.searchParams.get("refSelector") ?? "HEAD",
}
: null,
};
}