diff --git a/web/workspace/src/lib/workspace/styles/tickets.css b/web/workspace/src/lib/workspace/styles/tickets.css index c7098b5f..0fc65d2e 100644 --- a/web/workspace/src/lib/workspace/styles/tickets.css +++ b/web/workspace/src/lib/workspace/styles/tickets.css @@ -169,8 +169,32 @@ display: grid; align-content: start; gap: 0.55rem; + max-height: min(68vh, 48rem); + overflow-y: auto; + overscroll-behavior: contain; padding: 0.6rem; } + .ticket-lane-page-state { + display: flex; + justify-content: center; + gap: 0.5rem; + margin: 0; + padding: 0.45rem; + color: var(--text-muted); + font-size: 0.72rem; + text-align: center; + } + .ticket-lane-page-error { + align-items: center; + color: var(--danger); + } + .ticket-lane-page-error button { + border: 1px solid var(--line); + border-radius: 0.35rem; + background: var(--bg-raised); + color: inherit; + padding: 0.2rem 0.45rem; + } .ticket-card { display: grid; gap: 0.55rem; 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..649eb81d 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -105,6 +105,32 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket ); }); +Deno.test("ticket board paginates every lane independently with bounded summary requests", async () => { + const loadSource = await Deno.readTextFile( + "src/routes/w/[workspaceId]/tickets/+page.ts", + ); + const pageSource = await Deno.readTextFile( + "src/routes/w/[workspaceId]/tickets/+page.svelte", + ); + + assertIncludes(loadSource, 'limit: "30"'); + assertIncludes(loadSource, "states: states.join"); + assertIncludes(pageSource, "lane.page.next_cursor"); + assertIncludes(pageSource, "lane.loading"); + assertIncludes(pageSource, "mergeTickets"); + assertIncludes(pageSource, "onscroll"); + assertIncludes(pageSource, "Retry"); + if (loadSource.includes("limit=1000") || pageSource.includes("limit=1000")) { + throw new Error("Ticket board must not fetch the legacy 1000-item list"); + } + if ( + loadSource.includes("/tickets/query") || + pageSource.includes("/tickets/query") + ) { + throw new Error("Ticket board must use the bounded summary endpoint"); + } +}); + Deno.test("ticket panel starts the Orchestrator explicitly and gates orchestration actions", async () => { const panelSource = await Deno.readTextFile( "src/routes/w/[workspaceId]/tickets/+page.svelte", @@ -113,7 +139,10 @@ 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"); diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index f4c55b04..ff68779f 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -2,30 +2,94 @@ import { untrack } from "svelte"; import type { ApiResult } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; + import type { + QueryPage, + TicketListResponse, + TicketSummary, + } from "$lib/generated/ticket-api"; import { ticketLanes, type WorkspaceOrchestratorStatus, } from "$lib/workspace/tickets/ticket-panel"; - import type { - TicketListResponse, - TicketSummary, - } from "$lib/workspace/sidebar/types"; + import type { PageData } from "./$types"; - const { data } = $props<{ - data: { - workspaceId: string; - tickets: ApiResult; - orchestrator: ApiResult; - }; - }>(); + type LaneState = { + states: string[]; + tickets: TicketSummary[]; + page: QueryPage; + loading: boolean; + error: string | null; + }; - const initialTickets = untrack(() => data.tickets.data?.items ?? []); - let tickets = $state(initialTickets); + let { data }: { data: PageData } = $props(); + // svelte-ignore state_referenced_locally + let laneState = $state>( + Object.fromEntries( + Object.entries(data.ticketLanes).map(([laneId, lane]) => [ + laneId, + { + states: [...lane.states], + tickets: lane.response.items, + page: lane.response.page, + loading: false, + error: null, + }, + ]), + ), + ); let orchestrator = $state>( untrack(() => data.orchestrator), ); let orchestratorStarting = $state(false); - let lanes = $derived(ticketLanes(tickets)); + const tickets = $derived( + Object.values(laneState).flatMap((lane) => lane.tickets), + ); + const lanes = $derived(ticketLanes(tickets)); + + function mergeTickets( + current: TicketSummary[], + incoming: TicketSummary[], + ): TicketSummary[] { + const byId = new Map(current.map((ticket) => [ticket.id, ticket])); + for (const ticket of incoming) byId.set(ticket.id, ticket); + return [...byId.values()]; + } + + async function loadMore(laneId: string): Promise { + const lane = laneState[laneId]; + if (!lane || lane.loading || !lane.page.has_more || !lane.page.next_cursor) { + return; + } + lane.loading = true; + lane.error = null; + try { + const search = new URLSearchParams({ + limit: "30", + states: lane.states.join(","), + cursor: lane.page.next_cursor, + }); + const response = await fetch( + `/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`, + ); + if (!response.ok) { + throw new Error(`追加読み込みに失敗しました (${response.status})`); + } + const page = (await response.json()) as TicketListResponse; + lane.tickets = mergeTickets(lane.tickets, page.items); + lane.page = page.page; + } catch (error) { + lane.error = error instanceof Error ? error.message : String(error); + } finally { + lane.loading = false; + } + } + + function handleLaneScroll(event: Event, laneId: string): void { + const container = event.currentTarget as HTMLElement; + const remaining = + container.scrollHeight - container.scrollTop - container.clientHeight; + if (remaining <= 96) void loadMore(laneId); + } async function startOrchestrator() { if (orchestratorStarting || orchestrator.data?.online) return; @@ -45,7 +109,9 @@ } -Tickets · Yoi + + Tickets · {data.workspaceId} +
@@ -76,7 +142,7 @@
{tickets.length} - tickets + loaded tickets
@@ -93,6 +159,7 @@
{#each lanes as lane (lane.id)} + {@const pagination = laneState[lane.id]}
@@ -101,8 +168,11 @@
{lane.tickets.length}
- -
{/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..04ab28e8 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts @@ -1,23 +1,56 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import type { TicketListResponse } from "$lib/generated/ticket-api"; import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel"; -import type { TicketListResponse } from "$lib/workspace/sidebar/types"; 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`, +const LANE_STATES = { + "ready-planning": ["ready", "planning"], + "inprogress-queued": ["inprogress", "queued"], + "done-closed": ["done", "closed"], +} as const; + +export type TicketLaneId = keyof typeof LANE_STATES; + +export type TicketLanePage = { + states: readonly string[]; + response: TicketListResponse; +}; + +export const load: PageLoad = async ({ fetch, params }) => { + const workspaceId = params.workspaceId; + const [entries, orchestrator] = await Promise.all([ + Promise.all( + Object.entries(LANE_STATES).map(async ([laneId, states]) => { + const search = new URLSearchParams({ + limit: "30", + states: states.join(","), + }); + const response = await fetch( + `/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`, + ); + if (!response.ok) { + throw new Error( + `failed to load ${laneId} Ticket lane (${response.status})`, + ); + } + return [ + laneId, + { states: [...states], response: await response.json() }, + ] as const; + }), ), loadJson( fetch, - workspaceApiPath(params.workspaceId, "/orchestrator"), + workspaceApiPath(workspaceId, "/orchestrator"), ), ]); return { - workspaceId: params.workspaceId, - tickets, + workspaceId, + ticketLanes: Object.fromEntries(entries) as unknown as Record< + TicketLaneId, + TicketLanePage + >, orchestrator, }; -}) satisfies PageLoad; +};