diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 0a653e59..80ed257d 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -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") && diff --git a/web/workspace/src/lib/workspace/styles/tickets.css b/web/workspace/src/lib/workspace/styles/tickets.css index c7098b5f..f9f50bad 100644 --- a/web/workspace/src/lib/workspace/styles/tickets.css +++ b/web/workspace/src/lib/workspace/styles/tickets.css @@ -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; diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts index 842103cf..8c1b3386 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -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"); diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index 9bba505d..dc6c4466 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -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([ ["ready", 0], ["planning", 1], @@ -57,19 +71,74 @@ const STATE_SORT_ORDER = new Map([ ]); 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( diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index f4c55b04..b0139cdd 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -1,31 +1,50 @@ Tickets · Yoi @@ -50,11 +138,7 @@
-

Delivery

Tickets

-

- Plan, route, review, and close work without leaving the workspace. -

@@ -75,8 +159,8 @@ {/if}
- {tickets.length} - tickets + {displayedTicketCount} + tickets loaded
@@ -99,10 +183,15 @@

{lane.label}

- {lane.tickets.length} + + {lane.tickets.length}{lane.hasMore ? "+" : ""} + -
+
handleLaneScroll(event, lane.id)} + > {#each lane.tickets as ticket (ticket.id)} {:else} -
No tickets
+ {#if !lane.error} +
No tickets
+ {/if} {/each} + + {#if lane.loading} +

Loading 30 more…

+ {:else if lane.error} +
+ {lane.error} + +
+ {:else if !lane.hasMore && lane.tickets.length > 0} +

All tickets loaded.

+ {/if}
{/each} diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts index e115cab7..0e943920 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts @@ -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( - fetch, - `${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`, - ), +export const load: PageLoad = async ({ fetch, params }) => { + const workspaceId = params.workspaceId; + const ticketLanePagesPromise = Promise.all( + ticketLaneDefinitions().map(async (lane) => { + try { + const page = await loadJson( + fetch, + 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( fetch, - workspaceApiPath(params.workspaceId, "/orchestrator"), + workspaceApiPath(workspaceId, "/orchestrator"), ), ]); return { - workspaceId: params.workspaceId, - tickets, + workspaceId, + ticketLanePages, orchestrator, }; -}) satisfies PageLoad; +};