feat: paginate ticket workflow lanes

This commit is contained in:
2026-08-17 23:33:12 +09:00
parent 58ed04ac59
commit eec2be87ad
6 changed files with 352 additions and 45 deletions
@@ -218,19 +218,23 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
"Tickets and Objectives should each be a single sidebar link",
);
assert(
ticketsLoad.includes("?limit=1000") &&
ticketsPage.includes("data.tickets.data?.items") &&
ticketsPage.includes("ticketLanes(tickets)") &&
!ticketsLoad.includes("?limit=1000") &&
ticketsLoad.includes('workspaceApiPath(workspaceId, "/tickets/query")') &&
ticketsLoad.includes("ticketLaneDefinitions()") &&
ticketsLoad.includes("ticketLaneQuery(lane)") &&
ticketsPage.includes('class="ticket-kanban"') &&
ticketsPage.includes("lane.tickets"),
"Tickets list should load every workflow state and render a workspace Kanban board",
ticketsPage.includes('class="ticket-lane-cards"') &&
ticketsPage.includes("handleLaneScroll") &&
ticketsPage.includes("Loading 30 more"),
"Tickets list should query and incrementally scroll each Kanban lane",
);
assert(
ticketPanelModel.includes('label: "Ready + Planning"') &&
ticketPanelModel.includes('label: "In progress + Queued"') &&
ticketPanelModel.includes('label: "Done + Closed"') &&
ticketPanelModel.includes("updatedAt(right) - updatedAt(left)"),
"Ticket Kanban should combine related states and sort state priority before recency",
ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") &&
ticketPanelModel.includes('sort: "updated_desc"'),
"Ticket Kanban should combine related states into independent cursor pages",
);
assert(
generatedTicketApi.includes("Generated from yoi-workspace-server") &&
@@ -249,7 +253,9 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
ticketDetailPage.includes('mutate("state", "/state"') &&
ticketDetailPage.includes('mutate("queue", "/queue"') &&
!ticketDetailPage.includes("/merge-request/merge") &&
ticketDetailPage.includes("merged_result_commit") &&
ticketDetailPage.includes("mergeRequest.selector_from") &&
ticketDetailPage.includes("currentReview?.kind") &&
ticketDetailPage.includes("mergeEvent?.kind") &&
!ticketDetailPage.includes('mutate("review", "/review"') &&
ticketDetailPage.includes('mutate("close", "/close"') &&
ticketDetailPage.includes("ticketWorkerLaunchHref") &&
@@ -121,18 +121,25 @@
}
.ticket-kanban {
display: grid;
align-items: stretch;
grid-template-columns: repeat(3, minmax(18rem, 1fr));
gap: var(--space-3);
overflow-x: auto;
padding-bottom: var(--space-3);
}
.ticket-lane {
display: flex;
flex-direction: column;
height: calc(100dvh - 15rem);
min-height: 24rem;
max-height: 48rem;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 0.8rem;
background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
}
.ticket-lane-header {
flex: 0 0 auto;
justify-content: space-between;
padding: 0.75rem;
border-bottom: 1px solid var(--line);
@@ -167,9 +174,29 @@
}
.ticket-lane-cards {
display: grid;
flex: 1 1 auto;
align-content: start;
gap: 0.55rem;
min-height: 0;
overflow-y: auto;
padding: 0.6rem;
scrollbar-gutter: stable;
}
.ticket-lane-load-status {
margin: 0;
color: var(--text-muted);
padding: 0.5rem;
font-size: 0.7rem;
text-align: center;
}
.ticket-lane-load-error {
display: grid;
gap: 0.5rem;
color: #d66;
padding: 0.5rem;
}
.ticket-lane-load-error button {
justify-self: start;
}
.ticket-card {
display: grid;
@@ -1,4 +1,8 @@
import {
appendUniqueTicketSummaries,
TICKET_LANE_PAGE_SIZE,
ticketLaneDefinitions,
ticketLaneQuery,
ticketLanes,
ticketWorkerLaunchHref,
ticketWorkerMessage,
@@ -79,6 +83,49 @@ Deno.test("ticketLanes combines workflow states and sorts by state then update t
]);
});
Deno.test("ticket lane queries request independent pages of 30", () => {
const [readyPlanning, inprogressQueued, doneClosed] = ticketLaneDefinitions();
assertEquals(TICKET_LANE_PAGE_SIZE, 30);
assertEquals(ticketLaneQuery(readyPlanning).states, ["ready", "planning"]);
assertEquals(ticketLaneQuery(inprogressQueued).states, [
"inprogress",
"queued",
]);
assertEquals(ticketLaneQuery(doneClosed, "next-page"), {
attention: [],
cursor: "next-page",
event_kinds: [],
evidence: [],
limit: 30,
linked_objective_id: null,
query: null,
related_ticket_id: null,
relation_kind: null,
review_status: null,
sort: "updated_desc",
states: ["done", "closed"],
updated_after: null,
updated_before: null,
});
});
Deno.test("incremental Ticket pages preserve order and discard duplicate ids", () => {
const current = [
{ id: "first", title: "First", state: "ready", priority: "1" },
{ id: "second", title: "Second", state: "planning", priority: "2" },
] as TicketSummary[];
const incoming = [
{ id: "second", title: "Duplicate", state: "planning", priority: "2" },
{ id: "third", title: "Third", state: "planning", priority: "3" },
] as TicketSummary[];
assertEquals(
appendUniqueTicketSummaries(current, incoming).map((ticket) => ticket.id),
["first", "second", "third"],
);
});
Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => {
const ticket = {
id: "00001KYRRDVH9",
@@ -113,10 +160,22 @@ Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestrati
"src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
);
assertIncludes(panelSource, 'workspaceApiPath(data.workspaceId, "/orchestrator")');
assertIncludes(
panelSource,
'workspaceApiPath(data.workspaceId, "/orchestrator")',
);
assertIncludes(panelSource, '{ method: "POST" }');
assertIncludes(panelSource, "Start Orchestrator");
assertIncludes(panelSource, "orchestrator.data?.online");
assertIncludes(
panelSource,
'workspaceApiPath(data.workspaceId, "/tickets/query")',
);
assertIncludes(
panelSource,
"onscroll={(event) => handleLaneScroll(event, lane.id)}",
);
assertIncludes(panelSource, "Loading 30 more…");
assertIncludes(detailSource, "{#if orchestratorOnline}");
assertIncludes(detailSource, "!orchestratorOnline");
assertIncludes(detailSource, "Orchestrator offline");
@@ -1,4 +1,9 @@
import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api";
import type {
TicketDetail,
TicketQueryItem,
TicketQueryRequest,
TicketSummary,
} from "$lib/generated/ticket-api";
export const TICKET_STATES = [
"planning",
@@ -47,6 +52,15 @@ const LANE_DEFINITIONS = [
},
] as const;
export const TICKET_LANE_PAGE_SIZE = 30;
export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number];
export type TicketLaneId = TicketLaneDefinition["id"];
export type TicketCardSummary = Pick<
TicketSummary,
"id" | "title" | "state" | "priority" | "updated_at"
>;
const STATE_SORT_ORDER = new Map<string, number>([
["ready", 0],
["planning", 1],
@@ -57,19 +71,74 @@ const STATE_SORT_ORDER = new Map<string, number>([
]);
export type TicketLane = {
id: string;
id: TicketLaneId;
label: string;
states: readonly TicketState[];
tickets: TicketSummary[];
tickets: TicketCardSummary[];
};
function updatedAt(ticket: TicketSummary): number {
export function ticketLaneDefinitions(): readonly TicketLaneDefinition[] {
return LANE_DEFINITIONS;
}
export function ticketLaneQuery(
lane: { states: readonly TicketState[] },
cursor: string | null = null,
): TicketQueryRequest {
return {
attention: [],
cursor,
event_kinds: [],
evidence: [],
limit: TICKET_LANE_PAGE_SIZE,
linked_objective_id: null,
query: null,
related_ticket_id: null,
relation_kind: null,
review_status: null,
sort: "updated_desc",
states: [...lane.states],
updated_after: null,
updated_before: null,
};
}
export function ticketSummaryFromQueryItem(
item: TicketQueryItem,
): TicketCardSummary {
return {
id: item.id,
priority: item.priority === null ? "normal" : String(item.priority),
state: item.state,
title: item.title,
updated_at: item.updated_at,
};
}
export function appendUniqueTicketSummaries(
current: TicketCardSummary[],
incoming: TicketCardSummary[],
): TicketCardSummary[] {
const ids = new Set(current.map((ticket) => ticket.id));
return [
...current,
...incoming.filter((ticket) => {
if (ids.has(ticket.id)) return false;
ids.add(ticket.id);
return true;
}),
];
}
function updatedAt(ticket: TicketCardSummary): number {
if (!ticket.updated_at) return 0;
const parsed = Date.parse(ticket.updated_at);
return Number.isNaN(parsed) ? 0 : parsed;
}
export function sortTickets(tickets: TicketSummary[]): TicketSummary[] {
export function sortTickets(
tickets: TicketCardSummary[],
): TicketCardSummary[] {
return [...tickets].sort((left, right) => {
const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) -
(STATE_SORT_ORDER.get(right.state) ?? 99);
@@ -80,7 +149,7 @@ export function sortTickets(tickets: TicketSummary[]): TicketSummary[] {
});
}
export function ticketLanes(tickets: TicketSummary[]): TicketLane[] {
export function ticketLanes(tickets: TicketCardSummary[]): TicketLane[] {
return LANE_DEFINITIONS.map((definition) => ({
...definition,
tickets: sortTickets(
@@ -1,31 +1,50 @@
<script lang="ts">
import { untrack } from "svelte";
import type { TicketQueryResponse } from "$lib/generated/ticket-api";
import type { ApiResult } from "$lib/workspace/api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
ticketLanes,
appendUniqueTicketSummaries,
ticketLaneQuery,
ticketSummaryFromQueryItem,
type TicketCardSummary,
type TicketLaneId,
type TicketState,
type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel";
import type {
TicketListResponse,
TicketSummary,
} from "$lib/workspace/sidebar/types";
import "$lib/workspace/styles/tickets.css";
type LanePage = {
id: TicketLaneId;
label: string;
states: TicketState[];
tickets: TicketCardSummary[];
nextCursor: string | null;
hasMore: boolean;
error: string | null;
};
const { data } = $props<{
data: {
workspaceId: string;
tickets: ApiResult<TicketListResponse>;
ticketLanePages: LanePage[];
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
};
}>();
const initialTickets = untrack(() => data.tickets.data?.items ?? []);
let tickets = $state<TicketSummary[]>(initialTickets);
let lanes = $state<(LanePage & { loading: boolean })[]>(
untrack(() =>
data.ticketLanePages.map((lane: LanePage) => ({ ...lane, loading: false }))
),
);
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
untrack(() => data.orchestrator),
);
let orchestratorStarting = $state(false);
let lanes = $derived(ticketLanes(tickets));
const displayedTicketCount = $derived(
lanes.reduce((count, lane) => count + lane.tickets.length, 0),
);
const LANE_LOAD_THRESHOLD_PX = 96;
async function startOrchestrator() {
if (orchestratorStarting || orchestrator.data?.online) return;
@@ -43,6 +62,75 @@
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
}
function updateLane(
laneId: TicketLaneId,
update: (lane: LanePage & { loading: boolean }) =>
LanePage & { loading: boolean },
): void {
lanes = lanes.map((lane) => lane.id === laneId ? update(lane) : lane);
}
async function loadMoreTickets(laneId: TicketLaneId): Promise<void> {
const lane = lanes.find((candidate) => candidate.id === laneId);
if (!lane || lane.loading || (!lane.hasMore && !lane.error)) return;
updateLane(laneId, (current) => ({
...current,
loading: true,
error: null,
}));
let result: ApiResult<TicketQueryResponse>;
try {
result = await loadJson<TicketQueryResponse>(
fetch,
workspaceApiPath(data.workspaceId, "/tickets/query"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ticketLaneQuery(lane, lane.nextCursor)),
},
);
} catch (error) {
updateLane(laneId, (current) => ({
...current,
loading: false,
error: error instanceof Error
? error.message
: "Unable to load more Tickets.",
}));
return;
}
if (!result.data) {
updateLane(laneId, (current) => ({
...current,
loading: false,
error: result.error ?? "Unable to load more Tickets.",
}));
return;
}
const incoming = result.data.items.map(ticketSummaryFromQueryItem);
updateLane(laneId, (current) => ({
...current,
tickets: appendUniqueTicketSummaries(current.tickets, incoming),
nextCursor: result.data?.page.next_cursor ?? null,
hasMore: result.data?.page.has_more ?? false,
loading: false,
error: null,
}));
}
function handleLaneScroll(event: Event, laneId: TicketLaneId): void {
const element = event.currentTarget as HTMLElement;
const distanceFromBottom = element.scrollHeight - element.scrollTop -
element.clientHeight;
if (distanceFromBottom <= LANE_LOAD_THRESHOLD_PX) {
void loadMoreTickets(laneId);
}
}
</script>
<svelte:head><title>Tickets · Yoi</title></svelte:head>
@@ -50,11 +138,7 @@
<div class="workspace-page ticket-panel-page">
<header class="workspace-page-header ticket-panel-header">
<div>
<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>
<div class="ticket-panel-controls">
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
@@ -75,8 +159,8 @@
{/if}
</div>
<div class="ticket-panel-summary" aria-label="Ticket summary">
<strong>{tickets.length}</strong>
<span>tickets</span>
<strong>{displayedTicketCount}</strong>
<span>tickets loaded</span>
</div>
</div>
</header>
@@ -99,10 +183,15 @@
<span class="ticket-state-dot"></span>
<h2>{lane.label}</h2>
</div>
<span class="ticket-lane-count">{lane.tickets.length}</span>
<span class="ticket-lane-count">
{lane.tickets.length}{lane.hasMore ? "+" : ""}
</span>
</header>
<div class="ticket-lane-cards">
<div
class="ticket-lane-cards"
onscroll={(event) => handleLaneScroll(event, lane.id)}
>
{#each lane.tickets as ticket (ticket.id)}
<a
class="ticket-card"
@@ -116,8 +205,25 @@
</div>
</a>
{:else}
{#if !lane.error}
<div class="ticket-lane-empty">No tickets</div>
{/if}
{/each}
{#if lane.loading}
<p class="ticket-lane-load-status" aria-live="polite">Loading 30 more…</p>
{:else if lane.error}
<div class="ticket-lane-load-error">
<small>{lane.error}</small>
<button
class="workspace-secondary-button"
type="button"
onclick={() => loadMoreTickets(lane.id)}
>Retry</button>
</div>
{:else if !lane.hasMore && lane.tickets.length > 0}
<p class="ticket-lane-load-status">All tickets loaded.</p>
{/if}
</div>
</section>
{/each}
@@ -1,23 +1,63 @@
import type { TicketQueryResponse } from "$lib/generated/ticket-api";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { TicketListResponse } from "$lib/workspace/sidebar/types";
import {
ticketLaneDefinitions,
ticketLaneQuery,
ticketSummaryFromQueryItem,
type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
const [tickets, orchestrator] = await Promise.all([
loadJson<TicketListResponse>(
export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const ticketLanePagesPromise = Promise.all(
ticketLaneDefinitions().map(async (lane) => {
try {
const page = await loadJson<TicketQueryResponse>(
fetch,
`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`,
),
workspaceApiPath(workspaceId, "/tickets/query"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ticketLaneQuery(lane)),
},
);
return {
id: lane.id,
label: lane.label,
states: [...lane.states],
tickets: page.data?.items.map(ticketSummaryFromQueryItem) ?? [],
nextCursor: page.data?.page.next_cursor ?? null,
hasMore: page.data?.page.has_more ?? false,
error: page.error,
};
} catch (error) {
return {
id: lane.id,
label: lane.label,
states: [...lane.states],
tickets: [],
nextCursor: null,
hasMore: false,
error: error instanceof Error
? error.message
: "Unable to load Tickets.",
};
}
}),
);
const [ticketLanePages, orchestrator] = await Promise.all([
ticketLanePagesPromise,
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
workspaceApiPath(workspaceId, "/orchestrator"),
),
]);
return {
workspaceId: params.workspaceId,
tickets,
workspaceId,
ticketLanePages,
orchestrator,
};
}) satisfies PageLoad;
};