From a82e70047a43246b7cd1c791a7e6c7e971366461 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 13 Oct 2026 19:08:51 +0900 Subject: [PATCH] feat: replace Ticket kanban with query-based list --- docs/principle/ux-design.md | 4 +- .../web-ux/browser-tests/ticket_shell_test.ts | 401 +++++++++++++----- tools/web-ux/scenarios/tickets-dark.json | 97 ++++- tools/web-ux/scenarios/tickets.json | 97 ++++- web/workspace/deno.json | 2 +- .../console/worker-console.ui.test.ts | 24 +- .../src/lib/workspace/styles/tickets.css | 157 ------- .../workspace/tickets/ticket-panel.test.ts | 59 +-- .../src/lib/workspace/tickets/ticket-panel.ts | 62 +-- .../workspace/tickets/ticket-search.test.ts | 329 ++++++++++++++ .../lib/workspace/tickets/ticket-search.ts | 178 ++++++++ .../w/[workspaceId]/tickets/+page.svelte | 280 ++++++------ .../routes/w/[workspaceId]/tickets/+page.ts | 79 ++-- 13 files changed, 1137 insertions(+), 632 deletions(-) create mode 100644 web/workspace/src/lib/workspace/tickets/ticket-search.test.ts create mode 100644 web/workspace/src/lib/workspace/tickets/ticket-search.ts diff --git a/docs/principle/ux-design.md b/docs/principle/ux-design.md index d67d2b97b..878ad0d64 100644 --- a/docs/principle/ux-design.md +++ b/docs/principle/ux-design.md @@ -33,9 +33,9 @@ Workspace Webは、Workspaceの確定した状態を確認し、注意が必要 詳細では、対象の識別情報、現在の状態、注意が必要な事項を最初に示し、本文や履歴を主領域に置く。関連、target、review、案件判断、担当のhandlingをそれぞれ別の主体が確定する場合は、独立したsectionに分ける。 -## Ticket board +## Ticket一覧 -boardのlaneは案件状態の`open`/`closed`を表し、狭い画面では縦に並べる。`open`を着手可能・queue済み、`closed`を要求実現済みと読み替えない。終了理由と説明を案件の判断として示し、再開・訂正でも過去の判断を残す。 +Ticketは平坦なリストで表示し、検索条件は単一のクエリ欄にまとめる。状態別のlaneや独立したfilter群は設けず、案件状態は`state:open`/`state:closed`で指定する。`open`を着手可能・queue済み、`closed`を要求実現済みと読み替えない。終了理由と説明を案件の判断として示し、再開・訂正でも過去の判断を残す。 各Ticketにはkey、title、優先判断を変える情報を表示する。分類タグ、担当principal、自己申告のhandlingを案件状態と分けて示す。検索と絞込みは取得済みの一部分ではなく、Backendのqueryへ適用してからページを取得する。 diff --git a/tools/web-ux/browser-tests/ticket_shell_test.ts b/tools/web-ux/browser-tests/ticket_shell_test.ts index 0591becf2..6440cf57c 100644 --- a/tools/web-ux/browser-tests/ticket_shell_test.ts +++ b/tools/web-ux/browser-tests/ticket_shell_test.ts @@ -8,8 +8,7 @@ import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; async function withFixture( run: (browser: Browser, baseUrl: string) => Promise, ) { - const scenarioPath = - new URL("../scenarios/tickets.json", import.meta.url).pathname; + const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname; const scenario = await loadScenario(scenarioPath); const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); const port = (listener.addr as Deno.NetAddr).port; @@ -17,9 +16,8 @@ async function withFixture( const baseUrl = `http://127.0.0.1:${port}`; scenario.processes![0].args![4] = String(port); scenario.processes![0].readyUrl = `${baseUrl}/health`; - const logs = - new URL(`../../../target/web-ux/t743-browser/${port}/logs`, import.meta.url) - .pathname; + const logs = new URL(`../../../target/web-ux/t743-browser/${port}/logs`, import.meta.url) + .pathname; const processes = await startOwnedProcesses( scenario.processes!, scenarioPath, @@ -57,65 +55,58 @@ function queryResponse( ); } -Deno.test("Ticket filters and Orchestrator handling query the server beyond loaded pages and preserve filters on pagination", async () => { +async function search(page: Page, value: string) { + await page.getByRole("searchbox", { name: "Search tickets", exact: true }).fill(value); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await page.waitForURL((url) => url.searchParams.get("q") === value); +} +function ticketKey(page: Page, key: string) { + return page.locator(".ticket-list-key").filter({ hasText: new RegExp(`^${key}$`) }); +} +async function queryRequests(base: string) { + const requests = await (await fetch(`${base}/fixture-state`)).json(); + return requests.filter((row: { path: string }) => row.path === "/tickets/query"); +} + +Deno.test("single Ticket query searches beyond loaded rows and preserves qualifiers on pagination", async () => { await withFixture(async (browser, base) => { const page = await browser.newPage(); await page.goto(`${base}/w/tickets-pages/tickets`); - await page.locator('.ticket-lane[data-state="open"] .ticket-card').first() - .waitFor(); + await page.locator(".ticket-list-row").first().waitFor(); + assertEquals(await page.locator("a.ticket-list-row").count(), 30); + assertEquals(await ticketKey(page, "T-35").count(), 0); assertEquals( - await page.locator('.ticket-lane[data-state="open"] .ticket-card') - .count(), - 30, + await page.getByRole("searchbox", { name: "Search tickets" }).inputValue(), + "state:open", ); - assertEquals( - await page.locator(".ticket-card-id").filter({ hasText: /^T-35$/ }) - .count(), - 0, - ); - await page.getByRole("checkbox", { name: "UI", exact: true }).check(); - await page.getByRole("combobox", { - name: "Assigned principal", - exact: true, - }).selectOption("orchestrator"); - const filtered = queryResponse( - page, - (body) => - (body.tag_ids as string[])?.includes("tag-ui") && - (body.assigned_principal as { kind: string })?.kind === - "workspace_agent" && - (body.states as string[]).includes("open"), - ); - await page.getByRole("button", { name: "Apply filters", exact: true }) - .click(); - const response = await filtered; - assertEquals(response.request().postDataJSON(), { + assertEquals(await page.getByRole("form", { name: "Search tickets" }).count(), 1); + assertEquals(await page.locator(".ticket-kanban, .ticket-lane").count(), 0); + assertEquals(await page.getByRole("combobox").count(), 0); + assertEquals(await page.getByRole("checkbox").count(), 0); + assertEquals((await queryRequests(base)).length, 1); + const filtered = queryResponse(page, (body) => (body.tag_ids as string[])?.includes("tag-ui")); + await search(page, 'state:open tag:"UI" assignee:orchestrator'); + assertEquals((await filtered).request().postDataJSON(), { query: null, states: ["open"], tag_ids: ["tag-ui"], - assigned_principal: { - kind: "workspace_agent", - agent_key: "workspace-orchestrator", - }, + assigned_principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, handling_status: null, sort: "updated_desc", limit: 30, cursor: null, }); - await page.locator(".ticket-card-id").filter({ hasText: /^T-35$/ }) - .waitFor(); - assertEquals( - await page.locator('.ticket-lane[data-state="open"] .ticket-card') - .count(), - 2, - ); + await ticketKey(page, "T-35").waitFor(); + assertEquals(await page.locator(".ticket-list-row").count(), 2); + assertEquals((await queryRequests(base)).length, 2); await page.goto( - `${base}/w/tickets-pages/tickets?case=open&principal=orchestrator&tag=tag-investigation`, + `${base}/w/tickets-pages/tickets?q=${ + encodeURIComponent("state:open assignee:orchestrator tag:tag-investigation") + }`, ); - await page.getByRole("button", { name: "Load more open tickets" }) - .waitFor(); + await page.getByRole("button", { name: "Load more tickets", exact: true }).waitFor(); const next = queryResponse(page, (body) => body.cursor === "30"); - await page.getByRole("button", { name: "Load more open tickets" }).click(); + await page.getByRole("button", { name: "Load more tickets", exact: true }).click(); const nextRequest = (await next).request().postDataJSON(); assertEquals(nextRequest.tag_ids, ["tag-investigation"]); assertEquals(nextRequest.states, ["open"]); @@ -123,9 +114,22 @@ Deno.test("Ticket filters and Orchestrator handling query the server beyond load kind: "workspace_agent", agent_key: "workspace-orchestrator", }); - await page.locator(".ticket-card-id").filter({ hasText: /^T-40$/ }) - .waitFor(); - assertEquals(await page.locator(".ticket-card").count(), 39); + await ticketKey(page, "T-40").waitFor(); + assertEquals(await page.locator(".ticket-list-row").count(), 39); + assertEquals(new Set(await page.locator(".ticket-list-key").allTextContents()).size, 39); + const finalRow = page.locator("a.ticket-list-row").filter({ has: ticketKey(page, "T-40") }); + const href = await finalRow.getAttribute("href"); + assert(href); + await finalRow.focus(); + await finalRow.press("Enter"); + await page.waitForURL((url) => url.pathname === new URL(href, base).pathname); + await page.locator(".ticket-detail-header").waitFor(); + }); +}); + +Deno.test("Orchestrator retains handling filters and assignment pagination independently of Ticket search", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); await page.goto(`${base}/w/tickets-pages/orchestrator?handling=done`); await page.getByRole("button", { name: "Load more assignments" }).waitFor(); const moreHandling = queryResponse( @@ -139,19 +143,15 @@ Deno.test("Ticket filters and Orchestrator handling query the server beyond load agent_key: "workspace-orchestrator", }); assertEquals(handlingRequest.states, []); - await page.locator(".orchestrator-assignment").filter({ hasText: "T-40" }) - .waitFor(); + await page.locator(".orchestrator-assignment").filter({ hasText: "T-40" }).waitFor(); assertEquals(await page.locator(".orchestrator-assignment").count(), 38); - await page.getByRole("combobox", { - name: "Handling (self-reported)", - exact: true, - }).selectOption("waiting"); + await page.getByRole("combobox", { name: "Handling (self-reported)", exact: true }) + .selectOption("waiting"); const waiting = queryResponse( page, (body) => body.handling_status === "waiting" && body.cursor === null, ); - await page.getByRole("button", { name: "Apply filters", exact: true }) - .click(); + await page.getByRole("button", { name: "Apply filters", exact: true }).click(); await waiting; await page.locator(".orchestrator-assignment").filter({ hasText: "Awaiting the user's policy choice", @@ -161,55 +161,233 @@ Deno.test("Ticket filters and Orchestrator handling query the server beyond load }); }); -Deno.test("current close-reason filtering sends canonical known/custom reasons and never closes cases", async () => { +Deno.test("query URL and back-forward restore text, states and a fresh first page", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets-pages/tickets`); + await page.locator(".ticket-list-row").first().waitFor(); + const all = queryResponse(page, (body) => (body.states as string[])?.length === 0); + await search(page, ""); + assertEquals((await all).request().postDataJSON().query, null); + assertEquals(new URL(page.url()).searchParams.get("q"), ""); + await ticketKey(page, "T-4").waitFor(); + const more = queryResponse(page, (body) => body.cursor === "30"); + await page.getByRole("button", { name: "Load more tickets", exact: true }).click(); + await more; + await ticketKey(page, "T-40").waitFor(); + const closed = queryResponse(page, (body) => (body.states as string[])?.[0] === "closed"); + await search(page, "state:closed"); + assertEquals((await closed).request().postDataJSON().cursor, null); + await ticketKey(page, "T-4").waitFor(); + assertEquals(await page.locator(".ticket-list-row").count(), 1); + assertEquals(new URL(page.url()).searchParams.get("q"), "state:closed"); + const back = queryResponse( + page, + (body) => (body.states as string[])?.length === 0 && body.cursor === null, + ); + await page.goBack(); + await back; + await ticketKey(page, "T-1").waitFor(); + assertEquals(await page.getByRole("searchbox", { name: "Search tickets" }).inputValue(), ""); + assertEquals(await page.locator(".ticket-list-row").count(), 30); + const forward = queryResponse( + page, + (body) => (body.states as string[])?.[0] === "closed" && body.cursor === null, + ); + await page.goForward(); + await forward; + await ticketKey(page, "T-4").waitFor(); + assertEquals( + await page.getByRole("searchbox", { name: "Search tickets" }).inputValue(), + "state:closed", + ); + assertEquals(await page.locator(".ticket-list-row").count(), 1); + const text = queryResponse(page, (body) => body.query === "investigation"); + await search(page, "investigation"); + assertEquals((await text).request().postDataJSON().states, []); + await ticketKey(page, "T-2").waitFor(); + await ticketKey(page, "T-4").waitFor(); + const stateAll = queryResponse( + page, + (body) => body.query === null && (body.states as string[])?.length === 0, + ); + await search(page, "state:all"); + await stateAll; + await ticketKey(page, "T-1").waitFor(); + assertEquals(await page.locator(".ticket-list-row").count(), 30); + }); +}); + +Deno.test("quoted free text containing tag syntax does not depend on the tag catalog", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + let tagRequests = 0; + await page.route(/\/ticket-tags(?:\?|$)/, async (route) => { + tagRequests++; + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ message: "Tag catalog unavailable" }), + }); + }); + const response = queryResponse(page, (body) => body.query === "text tag:Ready"); + await page.goto(`${base}/w/tickets/tickets?q=${encodeURIComponent('"text tag:Ready"')}`); + const body = (await response).request().postDataJSON(); + assertEquals(body.query, "text tag:Ready"); + assertEquals(body.tag_ids, []); + assertEquals(body.states, []); + await page.getByText("No tickets match this query.", { exact: true }).waitFor(); + assertEquals(tagRequests, 0); + assertEquals(await page.getByRole("alert").count(), 0); + assertEquals((await queryRequests(base)).length, 1); + // 障害routeがquery string付きの実際のタグ取得も捕捉することを確認する。 + await search(page, "tag:Ready"); + await page.getByRole("alert").waitFor(); + assertEquals(tagRequests, 1); + assertEquals((await queryRequests(base)).length, 1); + }); +}); + +Deno.test("recognized invalid qualifiers show an error without issuing a broad Ticket query", async () => { await withFixture(async (browser, base) => { const page = await browser.newPage(); await page.goto(`${base}/w/tickets/tickets`); - await page.getByText("Close reason filter", { exact: true }).click(); - await page.getByRole("combobox", { - name: "Current close reason", - exact: true, - }).selectOption("completed"); - await page.getByRole("combobox", { name: "Case", exact: true }) - .selectOption("closed"); - const response = queryResponse( - page, - (body) => body.closure_reason === "completed", - ); - await page.getByRole("button", { name: "Apply filters", exact: true }) - .click(); + await page.locator(".ticket-list-row").first().waitFor(); + const before = (await queryRequests(base)).length; + for ( + const value of [ + "state:close", + "state:waiting", + "assignee:user/", + "assignee:worker/embedded", + "tag:", + 'reason:"', + ] + ) { + await search(page, value); + await page.waitForURL((url) => url.searchParams.get("q") === value); + await page.getByRole("alert").first().waitFor(); + assertEquals((await queryRequests(base)).length, before); + assertEquals( + await page.getByRole("button", { name: "Load more tickets", exact: true }).count(), + 0, + ); + } + const recovered = queryResponse(page, (body) => (body.states as string[])?.[0] === "open"); + await search(page, "state:open"); + await recovered; + await ticketKey(page, "T-1").waitFor(); + assertEquals(await page.getByRole("alert").count(), 0); + }); +}); + +Deno.test("current close-reason query sends canonical known/custom reasons and never closes cases", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets`); + await page.locator(".ticket-list-row").first().waitFor(); + const response = queryResponse(page, (body) => body.closure_reason === "completed"); + await search(page, "state:closed reason:completed"); assertEquals((await response).request().postDataJSON().states, ["closed"]); - await page.locator('.ticket-lane[data-state="open"]').waitFor({ - state: "hidden", - }); - assertEquals(await page.locator(".ticket-card").count(), 1); - assertEquals(await page.locator(".ticket-card-id").textContent(), "T-4"); - await page.getByRole("combobox", { - name: "Current close reason", - exact: true, - }).selectOption("other"); - await page.getByLabel("Close reason text", { exact: true }).fill( - "Superseded plan", - ); + await ticketKey(page, "T-4").waitFor(); + assertEquals(await page.locator(".ticket-list-row").count(), 1); const custom = queryResponse( page, - (body) => - (body.closure_reason as { other: string })?.other === "Superseded plan", + (body) => (body.closure_reason as { other: string })?.other === "Superseded plan", ); - await page.getByRole("button", { name: "Apply filters", exact: true }) - .click(); + await search(page, 'reason:"Superseded plan"'); assertEquals((await custom).request().postDataJSON().closure_reason, { other: "Superseded plan", }); - await page.getByText("No tickets", { exact: true }).waitFor(); - assertEquals( - (await detail(base, "T-4")).current_closure.reason, - "completed", - ); + await page.getByText("No tickets match this query.", { exact: true }).waitFor(); + assertEquals((await detail(base, "T-4")).current_closure.reason, "completed"); const requests = await (await fetch(`${base}/fixture-state`)).json(); - assert( - !requests.some((row: { path: string }) => row.path.endsWith("/case")), - ); + assert(!requests.some((row: { path: string }) => row.path.endsWith("/case"))); + }); +}); + +Deno.test("assignee qualifiers send exact user and runtime-worker identities, not Worker liveness", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets`); + await page.locator(".ticket-list-row").first().waitFor(); + for ( + const [value, principal] of [ + ["assignee:user/alice", { kind: "user", account_id: "alice" }], + ["assignee:worker/embedded/running-worker", { + kind: "worker", + runtime_id: "embedded", + worker_id: "running-worker", + }], + ] as const + ) { + const response = queryResponse( + page, + (body) => JSON.stringify(body.assigned_principal) === JSON.stringify(principal), + ); + await search(page, value); + assertEquals((await response).request().postDataJSON().assigned_principal, principal); + // All fixture delegations are to the Workspace agent. T-3's Running + // Worker observation must not masquerade as an assigned principal. + await page.getByText("No tickets match this query.", { exact: true }).waitFor(); + assertEquals(await page.locator(".ticket-list-row").count(), 0); + } + }); +}); + +async function boundedGate(promise: Promise) { + let timer: number | undefined; + try { + await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Pagination response gate timed out")), 30_000); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +Deno.test("late old-query pagination cannot append rows after query navigation", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets-pages/tickets`); + await page.getByRole("button", { name: "Load more tickets", exact: true }).waitFor(); + const held = Promise.withResolvers(); + const release = Promise.withResolvers(); + const finished = Promise.withResolvers(); + await page.route("**/tickets/query", async (route) => { + if (route.request().postDataJSON().cursor !== "30") return await route.continue(); + const response = await route.fetch(); + held.resolve(); + await release.promise; + await route.fulfill({ response }); + finished.resolve(); + }); + try { + await page.getByRole("button", { name: "Load more tickets", exact: true }).click(); + await boundedGate(held.promise); + const changed = queryResponse( + page, + (body) => (body.states as string[])?.[0] === "closed" && body.cursor === null, + ); + await search(page, "state:closed"); + await changed; + await ticketKey(page, "T-4").waitFor(); + release.resolve(); + await boundedGate(finished.promise); + await page.evaluate( + "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))", + ); + assertEquals(await page.locator(".ticket-list-key").allTextContents(), ["T-4"]); + assertEquals( + await page.getByRole("button", { name: "Load more tickets", exact: true }).count(), + 0, + ); + } finally { + release.resolve(); + } }); }); @@ -367,9 +545,7 @@ Deno.test("waiting answers retain a delegation, done reissue retains results, an ); const requests = await (await fetch(`${base}/fixture-state`)).json(); assert( - !requests.some((r: { path: string }) => - /\/(queue|ready|state)$/.test(r.path) - ), + !requests.some((r: { path: string }) => /\/(queue|ready|state)$/.test(r.path)), ); }); }); @@ -473,9 +649,7 @@ Deno.test("repository-free creation and canonical tag create/attach/rename/delet ]); const requests = await (await fetch(`${base}/fixture-state`)).json(); assert( - !requests.some((row: { path: string }) => - /assignment|\/case$/.test(row.path) - ), + !requests.some((row: { path: string }) => /assignment|\/case$/.test(row.path)), ); }); }); @@ -501,8 +675,7 @@ Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permi }).count(), 0, ); - const saved = - (await detail(base, "T-1")).assignment_tracking[0].assignment_id; + const saved = (await detail(base, "T-1")).assignment_tracking[0].assignment_id; const beforeRetry = await (await fetch(`${base}/fixture-state`)).json(); const first = beforeRetry.filter((item: { path: string }) => item.path.endsWith("/assignments/orchestrator") @@ -552,8 +725,7 @@ Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permi }, ); assert(replacement.ok); - expectedAssignment = - (await replacement.json()).assignment.assignment_id; + expectedAssignment = (await replacement.json()).assignment.assignment_id; } await page.getByRole("button", { name: "Request again (new assignment)", @@ -592,9 +764,8 @@ Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permi Deno.test("case and handling matrix renders without overflow in both themes and empty/error/unknown states stay distinct", async () => { await withFixture(async (browser, base) => { - const output = - new URL("../../../target/web-ux/t743-browser/matrix", import.meta.url) - .pathname; + const output = new URL("../../../target/web-ux/t743-browser/matrix", import.meta.url) + .pathname; await Deno.mkdir(output, { recursive: true }); for (const colorScheme of ["light", "dark"] as const) { for (const width of [1440, 768, 390]) { @@ -609,12 +780,12 @@ Deno.test("case and handling matrix renders without overflow in both themes and page.on("pageerror", (error) => errors.push(String(error))); for ( const [workspace, route, ready] of [ - ["tickets", "tickets", ".ticket-card"], + ["tickets", "tickets?q=", ".ticket-list-row"], ["tickets", "orchestrator", ".orchestrator-assignment"], - ["tickets-long", "tickets", ".ticket-card"], + ["tickets-long", "tickets?q=", ".ticket-list-row"], ["tickets-empty", "orchestrator", ".orchestrator-assignments"], ["tickets-unknown", "orchestrator", ".orchestrator-assignment"], - ["tickets-error", "tickets", ".ticket-lane-page-error"], + ["tickets-error", "tickets", "[role=alert]"], ["tickets", "tickets/T-1", ".ticket-detail-header"], ["tickets", "tickets/T-2", ".ticket-worker-observation strong"], ["tickets", "tickets/T-3", ".ticket-worker-observation strong"], @@ -651,10 +822,10 @@ Deno.test("case and handling matrix renders without overflow in both themes and assertEquals( await page.getByRole("button", { name: "Retry", exact: true }) .count(), - 2, + 1, ); assertEquals( - await page.getByText("No tickets", { exact: true }).count(), + await page.getByText("No tickets match this query.", { exact: true }).count(), 0, ); } diff --git a/tools/web-ux/scenarios/tickets-dark.json b/tools/web-ux/scenarios/tickets-dark.json index 6975e108f..352e161e4 100644 --- a/tools/web-ux/scenarios/tickets-dark.json +++ b/tools/web-ux/scenarios/tickets-dark.json @@ -141,11 +141,11 @@ "id": "list", "label": "Member creates a repository-free Ticket", "path": "/w/tickets/tickets", - "goal": "Server-filter open and closed cases by canonical tags and current principal", - "dataState": "Open+done, open+waiting, inprogress with MR, closed with history", + "goal": "Search one server-ordered Ticket list with typed qualifiers; create without a repository", + "dataState": "Default open cases; closed reason search and optional creation", "ready": { "kind": "selector", - "selector": ".ticket-kanban" + "selector": ".ticket-list-row" }, "capturePoints": [ { @@ -169,30 +169,27 @@ "fullPage": true }, { - "id": "last-case", - "label": "Closed case and final handling result", - "interaction": [ - { - "action": "press", - "selector": ".ticket-lane[data-state=closed] .ticket-card", - "key": "End" - } - ], - "fullPage": true - }, - { - "id": "reason-filter", - "label": "Current close reason versus historical reason search", + "id": "reason-query", + "label": "Closed cases with a current completed reason", "interaction": [ { "action": "click", "selector": ".ticket-panel-controls button:has-text(\"Cancel\")" }, + { + "action": "fill", + "selector": "input[aria-label=\"Search tickets\"]", + "value": "state:closed reason:completed" + }, { "action": "click", - "selector": ".ticket-reason-filter summary" + "selector": "form[aria-label=\"Search tickets\"] button:has-text(\"Search\")" } ], + "ready": { + "kind": "selector", + "selector": ".ticket-list-row:has(.ticket-list-key:text-is(\"T-4\"))" + }, "fullPage": true } ] @@ -265,13 +262,13 @@ }, { "id": "long", - "path": "/w/tickets-long/tickets", + "path": "/w/tickets-long/tickets?q=", "label": "Long list titles and handling", "goal": "Long list titles and handling", "dataState": "Long titles and multiline results", "ready": { "kind": "selector", - "selector": ".ticket-kanban" + "selector": ".ticket-list-row" }, "capturePoints": [ { @@ -285,7 +282,7 @@ "interaction": [ { "action": "press", - "selector": ".ticket-lane[data-state=closed] .ticket-card", + "selector": ".ticket-list-rows > li:last-child .ticket-list-row", "key": "End" } ], @@ -316,10 +313,10 @@ "path": "/w/tickets-error/tickets", "label": "Query failure recovery", "goal": "Query failure recovery", - "dataState": "503 query, scoped errors and retry", + "dataState": "503 query, single list error and retry", "ready": { "kind": "selector", - "selector": ".ticket-lane-page-error" + "selector": "[role=alert]" }, "capturePoints": [ { @@ -364,6 +361,60 @@ "fullPage": true } ] + }, + { + "id": "mixed", + "path": "/w/tickets/tickets?q=", + "label": "Explicit empty query includes both case states", + "goal": "Scan open and closed cases in one server-ordered list", + "dataState": "Open+done, open+waiting, inprogress with MR, closed with history", + "ready": { + "kind": "selector", + "selector": ".ticket-list-row:has(.ticket-list-key:text-is(\"T-4\"))" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Mixed-state flat list", + "fullPage": true + } + ] + }, + { + "id": "list-empty", + "path": "/w/tickets-empty/tickets?q=", + "label": "No matching Tickets", + "goal": "Distinguish an empty list from a failed read", + "dataState": "Empty response", + "ready": { + "kind": "selector", + "selector": ".ticket-list :text-is(\"No tickets match this query.\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Empty Ticket list", + "fullPage": true + } + ] + }, + { + "id": "invalid-query", + "path": "/w/tickets/tickets?q=state%3Aclose", + "label": "Invalid recognized state qualifier", + "goal": "Correct a query without issuing a broad fallback read", + "dataState": "Rejected close alias", + "ready": { + "kind": "selector", + "selector": "[role=alert]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Query validation error", + "fullPage": true + } + ] } ], "processes": [ diff --git a/tools/web-ux/scenarios/tickets.json b/tools/web-ux/scenarios/tickets.json index 2605ff64d..c9b362fe1 100644 --- a/tools/web-ux/scenarios/tickets.json +++ b/tools/web-ux/scenarios/tickets.json @@ -141,11 +141,11 @@ "id": "list", "label": "Member creates a repository-free Ticket", "path": "/w/tickets/tickets", - "goal": "Server-filter open and closed cases by canonical tags and current principal", - "dataState": "Open+done, open+waiting, inprogress with MR, closed with history", + "goal": "Search one server-ordered Ticket list with typed qualifiers; create without a repository", + "dataState": "Default open cases; closed reason search and optional creation", "ready": { "kind": "selector", - "selector": ".ticket-kanban" + "selector": ".ticket-list-row" }, "capturePoints": [ { @@ -169,30 +169,27 @@ "fullPage": true }, { - "id": "last-case", - "label": "Closed case and final handling result", - "interaction": [ - { - "action": "press", - "selector": ".ticket-lane[data-state=closed] .ticket-card", - "key": "End" - } - ], - "fullPage": true - }, - { - "id": "reason-filter", - "label": "Current close reason versus historical reason search", + "id": "reason-query", + "label": "Closed cases with a current completed reason", "interaction": [ { "action": "click", "selector": ".ticket-panel-controls button:has-text(\"Cancel\")" }, + { + "action": "fill", + "selector": "input[aria-label=\"Search tickets\"]", + "value": "state:closed reason:completed" + }, { "action": "click", - "selector": ".ticket-reason-filter summary" + "selector": "form[aria-label=\"Search tickets\"] button:has-text(\"Search\")" } ], + "ready": { + "kind": "selector", + "selector": ".ticket-list-row:has(.ticket-list-key:text-is(\"T-4\"))" + }, "fullPage": true } ] @@ -265,13 +262,13 @@ }, { "id": "long", - "path": "/w/tickets-long/tickets", + "path": "/w/tickets-long/tickets?q=", "label": "Long list titles and handling", "goal": "Long list titles and handling", "dataState": "Long titles and multiline results", "ready": { "kind": "selector", - "selector": ".ticket-kanban" + "selector": ".ticket-list-row" }, "capturePoints": [ { @@ -285,7 +282,7 @@ "interaction": [ { "action": "press", - "selector": ".ticket-lane[data-state=closed] .ticket-card", + "selector": ".ticket-list-rows > li:last-child .ticket-list-row", "key": "End" } ], @@ -316,10 +313,10 @@ "path": "/w/tickets-error/tickets", "label": "Query failure recovery", "goal": "Query failure recovery", - "dataState": "503 query, scoped errors and retry", + "dataState": "503 query, single list error and retry", "ready": { "kind": "selector", - "selector": ".ticket-lane-page-error" + "selector": "[role=alert]" }, "capturePoints": [ { @@ -364,6 +361,60 @@ "fullPage": true } ] + }, + { + "id": "mixed", + "path": "/w/tickets/tickets?q=", + "label": "Explicit empty query includes both case states", + "goal": "Scan open and closed cases in one server-ordered list", + "dataState": "Open+done, open+waiting, inprogress with MR, closed with history", + "ready": { + "kind": "selector", + "selector": ".ticket-list-row:has(.ticket-list-key:text-is(\"T-4\"))" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Mixed-state flat list", + "fullPage": true + } + ] + }, + { + "id": "list-empty", + "path": "/w/tickets-empty/tickets?q=", + "label": "No matching Tickets", + "goal": "Distinguish an empty list from a failed read", + "dataState": "Empty response", + "ready": { + "kind": "selector", + "selector": ".ticket-list :text-is(\"No tickets match this query.\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Empty Ticket list", + "fullPage": true + } + ] + }, + { + "id": "invalid-query", + "path": "/w/tickets/tickets?q=state%3Aclose", + "label": "Invalid recognized state qualifier", + "goal": "Correct a query without issuing a broad fallback read", + "dataState": "Rejected close alias", + "ready": { + "kind": "selector", + "selector": "[role=alert]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Query validation error", + "fullPage": true + } + ] } ], "processes": [ diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 1cef3e768..82c3b97ad 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts", "test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts", 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 ba24943a7..bd0e09663 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 @@ -319,7 +319,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () ); }); -Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", async () => { +Deno.test("workspace Tickets surface provides query list and lifecycle controls", async () => { const ticketsNav = await Deno.readTextFile( new URL("../sidebar/TicketsNavSection.svelte", import.meta.url), ); @@ -353,9 +353,6 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as import.meta.url, ), ); - const ticketPanelModel = await Deno.readTextFile( - new URL("../tickets/ticket-panel.ts", import.meta.url), - ); const generatedTicketApi = await Deno.readTextFile( new URL("../../generated/ticket-api.ts", import.meta.url), ); @@ -391,21 +388,14 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as !mergeRequestsNav.includes("review and integration resources"), "Tickets, Objectives, and Merge Requests should each be a single primary sidebar link", ); + // クエリ・ページングの振舞いはticket-search.testとTicket browser suiteで検証する。 assert( - ticketsLoad.includes("ticketQueryRequest(filters, [state])") && + ticketsLoad.includes("compileTicketSearch") && ticketsLoad.includes("/tickets/query") && - ticketsLoad.includes('method: "POST"') && - ticketsPage.includes('class="ticket-kanban"') && - ticketsPage.includes("laneState") && - ticketsPage.includes("loadMore(lane.id)"), - "Tickets list must apply shared server query filters before paging case lanes", - ); - assert( - ticketPanelModel.includes('label: "Open"') && - ticketPanelModel.includes('label: "Closed"') && - ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") && - ticketPanelModel.includes("nextTicketLaneVisibleCount"), - "Ticket Kanban should separate cases into independent 30-item display windows", + ticketsPage.includes('class="ticket-list"') && + !ticketsPage.includes("TicketQueryFilters") && + !ticketsPage.includes("ticket-kanban"), + "Tickets use one server query and a flat list rather than lanes or separate filters", ); assert( generatedTicketApi.includes( diff --git a/web/workspace/src/lib/workspace/styles/tickets.css b/web/workspace/src/lib/workspace/styles/tickets.css index cfc6046aa..cf6e83b02 100644 --- a/web/workspace/src/lib/workspace/styles/tickets.css +++ b/web/workspace/src/lib/workspace/styles/tickets.css @@ -62,9 +62,6 @@ .ticket-detail-kicker, .ticket-section-heading, .ticket-control-card > header, - .ticket-lane-header, - .ticket-lane-header > div, - .ticket-card-meta, .ticket-role-actions { display: flex; align-items: center; @@ -118,160 +115,6 @@ .orchestrator-status[data-online="true"] .orchestrator-status-dot { background: var(--success); } - .ticket-panel-summary { - display: grid; - justify-items: end; - color: var(--text-muted); - } - .ticket-panel-summary strong { - color: var(--text-strong); - font-size: var(--font-size-compact); - line-height: var(--line-height-compact); - } - .ticket-kanban { - display: grid; - align-items: stretch; - grid-template-columns: repeat(2, 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); - } - .ticket-lane-header > div { - gap: 0.45rem; - } - .ticket-lane-header h2 { - margin: 0; - font-size: var(--font-size-compact); - text-transform: uppercase; - letter-spacing: 0.04em; - } - .ticket-state-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; - background: var(--text-muted); - } - .ticket-lane[data-state="open"] .ticket-state-dot { - background: var(--success); - } - .ticket-lane[data-state="closed"] .ticket-state-dot { - background: var(--accent); - } - .ticket-lane-count { - color: var(--text-muted); - font-size: var(--font-size-compact); - } - .ticket-lane-cards { - display: grid; - flex: 1 1 auto; - align-content: start; - gap: 0.55rem; - min-height: 0; - max-height: min(68vh, 48rem); - overflow-y: auto; - overscroll-behavior: contain; - padding: 0.6rem; - scrollbar-gutter: stable; - } - .ticket-lane-load-status { - margin: 0; - color: var(--text-muted); - padding: 0.5rem; - font-size: var(--font-size-compact); - text-align: center; - } - .ticket-lane-page-state { - display: flex; - justify-content: center; - gap: 0.5rem; - margin: 0; - padding: 0.45rem; - color: var(--text-muted); - font-size: var(--font-size-compact); - 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; - border: 1px solid var(--line); - border-radius: 0.65rem; - background: var(--bg-raised); - color: var(--text-strong); - padding: 0.75rem; - text-decoration: none; - } - .ticket-card:hover { - border-color: var(--accent); - } - .ticket-card-id { - overflow: hidden; - color: var(--text-muted); - font-family: var(--font-mono); - font-size: var(--font-size-compact); - text-overflow: ellipsis; - } - .ticket-card strong { - font-size: var(--font-size-compact); - line-height: 1.35; - } - .ticket-card-meta { - justify-content: space-between; - color: var(--text-muted); - font-size: var(--font-size-compact); - } - .ticket-risk-list { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - } - .ticket-risk-list span { - display: inline-flex; - align-items: center; - gap: var(--space-1); - color: var(--warning); - font-size: var(--font-size-compact); - } - .ticket-risk-list span::before { - width: 0.4rem; - height: 0.4rem; - border-radius: 50%; - background: currentColor; - content: ""; - } - .ticket-lane-empty { - padding: 1.2rem 0.4rem; - color: var(--text-muted); - font-size: var(--font-size-compact); - text-align: center; - } .ticket-detail-page { container: ticket-detail / inline-size; gap: var(--space-4); 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 676e21bf2..f1af5f92c 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -3,18 +3,12 @@ import { currentOrchestratorAssignment, handlingLabel, handlingSummary, - nextTicketLaneVisibleCount, readTicketFilters, - TICKET_LANE_PAGE_SIZE, - ticketLanes, ticketQueryRequest, ticketWorkerLaunchHref, ticketWorkerMessage, } from "./ticket-panel.ts"; -import type { - TicketDetail, - TicketListItemSummary as TicketSummary, -} from "../../generated/ticket-api.ts"; +import type { TicketDetail } from "../../generated/ticket-api.ts"; declare const Deno: { test(name: string, fn: () => void | Promise): void; @@ -29,57 +23,6 @@ function assertEquals(actual: T, expected: T): void { } } -Deno.test("ticketLanes separates open and closed cases and orders updates deterministically", () => { - const tickets = [ - { - id: "old", - state: "open", - title: "Open old", - tags: [{ - workspace_id: "workspace-a", - id: "tag-closed", - name: "closed", - last_operation_key: "create-closed", - deleted: false, - }], - updated_at: "2026-07-29T12:00:00Z", - }, - { - id: "new", - state: "open", - title: "Open new", - tags: [], - updated_at: "2026-07-30T12:00:00Z", - }, - { - id: "closed", - state: "closed", - title: "Closed", - tags: [{ - workspace_id: "workspace-a", - id: "tag-ready", - name: "ready", - last_operation_key: "create-ready", - deleted: false, - }], - updated_at: null, - }, - ] as TicketSummary[]; - const lanes = ticketLanes(tickets); - assertEquals(lanes.map((lane) => lane.id), ["open", "closed"]); - assertEquals(lanes[0].tickets.map((ticket) => ticket.id), ["new", "old"]); - assertEquals(lanes[1].tickets.map((ticket) => ticket.id), ["closed"]); -}); - -Deno.test("ticket lane visibility advances in bounded pages of 30", () => { - assertEquals(TICKET_LANE_PAGE_SIZE, 30); - assertEquals(nextTicketLaneVisibleCount(0, 95), 30); - assertEquals(nextTicketLaneVisibleCount(30, 95), 60); - assertEquals(nextTicketLaneVisibleCount(60, 95), 90); - assertEquals(nextTicketLaneVisibleCount(90, 95), 95); - assertEquals(nextTicketLaneVisibleCount(95, 95), 95); -}); - Deno.test("generic Ticket launch carries natural text without role or resource claims", () => { const requests = [ "Review is unnecessary; complete the requested work.", diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index 5e743bd62..2b526bf8b 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -5,7 +5,6 @@ import type { TicketAssignmentTrackingStatus, TicketClosureReason, TicketDetail, - TicketListItemSummary, TicketQueryItem, TicketQueryRequest, } from "#lib/generated/ticket-api.ts"; @@ -13,65 +12,6 @@ import type { export const TICKET_STATES = ["open", "closed"] as const; export type TicketState = (typeof TICKET_STATES)[number]; export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse; -const LANE_DEFINITIONS = [ - { id: "open", label: "Open", states: ["open"] }, - { id: "closed", label: "Closed", states: ["closed"] }, -] 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< - TicketListItemSummary, - "id" | "resource_key" | "title" | "state" | "priority" | "updated_at" - > - & Partial< - Pick - >; - -export type TicketLane = { - id: TicketLaneId; - label: string; - states: readonly TicketState[]; - tickets: TicketCardSummary[]; -}; - -export function nextTicketLaneVisibleCount( - current: number, - total: number, -): number { - return Math.min(total, current + TICKET_LANE_PAGE_SIZE); -} - -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: TicketCardSummary[], -): TicketCardSummary[] { - return [...tickets].sort((left, right) => { - const updatedDelta = updatedAt(right) - updatedAt(left); - if (updatedDelta !== 0) return updatedDelta; - return left.id.localeCompare(right.id); - }); -} - -export function ticketLanes(tickets: TicketCardSummary[]): TicketLane[] { - return LANE_DEFINITIONS.map((definition) => ({ - ...definition, - tickets: sortTickets( - tickets.filter((ticket) => - (definition.states as readonly string[]).includes(ticket.state) - ), - ), - })); -} - export function ticketWorkerMessage(ticketId: string): string { return `Work on Ticket ${ticketId}.`; } @@ -228,7 +168,7 @@ export function ticketQueryRequest( assigned_principal: filterPrincipal(filters), handling_status: handlingStatus, sort: "updated_desc", - limit: TICKET_LANE_PAGE_SIZE as TicketQueryRequest["limit"], + limit: 30 as TicketQueryRequest["limit"], cursor, }; } diff --git a/web/workspace/src/lib/workspace/tickets/ticket-search.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-search.test.ts new file mode 100644 index 000000000..712233087 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/ticket-search.test.ts @@ -0,0 +1,329 @@ +import { + compileTicketSearch, + DEFAULT_TICKET_SEARCH, + ticketSearchNeedsTags, +} from "./ticket-search.ts"; +import type { WorkspaceTag } from "../../generated/ticket-api.ts"; + +declare const Deno: { + test(name: string, fn: () => void): void; +}; + +function assertEquals(actual: T, expected: T): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} + +function assertSearchError( + input: string, + messagePart: string, + availableTags = tags, +): void { + try { + compileTicketSearch(input, availableTags); + } catch (error) { + if (!(error instanceof Error) || !error.message.includes(messagePart)) { + throw new Error( + `expected error containing ${JSON.stringify(messagePart)}, got ${ + String(error) + }`, + ); + } + return; + } + throw new Error(`expected ${JSON.stringify(input)} to fail`); +} + +const tags: readonly WorkspaceTag[] = Object.freeze([ + Object.freeze({ + workspace_id: "workspace", + id: "tag-1", + name: "Ready", + last_operation_key: "create-1", + deleted: false, + }), + Object.freeze({ + workspace_id: "workspace", + id: "tag-2", + name: "Needs review", + last_operation_key: "create-2", + deleted: false, + }), + Object.freeze({ + workspace_id: "workspace", + id: "tag-3", + name: "Retired", + last_operation_key: "delete-3", + deleted: true, + }), +]); + +Deno.test("only actual tag qualifiers require the tag catalog", () => { + for ( + const input of [ + "", + "state:open", + '"text tag:Ready"', + 'reason:"text tag:Ready"', + "TAG:Ready", + "https://example.test/tag:Ready", + ] + ) { + assertEquals(ticketSearchNeedsTags(input), false); + } + for (const input of ['tag:"Needs review"', "text tag:Ready", "tag:"]) { + assertEquals(ticketSearchNeedsTags(input), true); + } +}); + +Deno.test("empty search selects all states with bounded first-page defaults", () => { + for (const input of ["", " \t\n "]) { + assertEquals(compileTicketSearch(input, tags), { + query: null, + states: [], + tag_ids: [], + assigned_principal: null, + handling_status: null, + sort: "updated_desc", + limit: 30, + cursor: null, + }); + } + assertEquals(DEFAULT_TICKET_SEARCH, "state:open"); + assertEquals(compileTicketSearch(DEFAULT_TICKET_SEARCH, tags).states, [ + "open", + ]); +}); + +Deno.test("plain words preserve case and do not implicitly restrict states", () => { + const request = compileTicketSearch(" Fix\tHTTP Cache\nBug ", tags); + assertEquals(request.query, "Fix HTTP Cache Bug"); + assertEquals(request.states, []); +}); + +Deno.test("state filters never leak into backend free text", () => { + for ( + const [value, expected] of [["open", ["open"]], ["closed", ["closed"]], [ + "all", + [], + ]] as const + ) { + const request = compileTicketSearch(`Fix state:${value} HTTP`, tags); + assertEquals(request.states, [...expected]); + assertEquals(request.query, "Fix HTTP"); + assertEquals(compileTicketSearch(`state:${value}`, tags).query, null); + } +}); + +Deno.test("unknown qualifiers and URLs remain ordinary free text", () => { + const input = + 'https://Example.test/state:closed label:Ready handling:done sort:oldest close:yes field:"Some Text"'; + const request = compileTicketSearch(input, tags); + assertEquals(request.query, input); + assertEquals(request.states, []); + assertEquals(request.tag_ids, []); + assertEquals(request.handling_status, null); + assertEquals(request.sort, "updated_desc"); +}); + +Deno.test("quoted ordinary text stays text while quoted qualifier values are decoded", () => { + const request = compileTicketSearch( + '"state:closed" "HTTP Cache" tag:"Needs review" state:"open"', + tags, + ); + assertEquals(request.query, "state:closed HTTP Cache"); + assertEquals(request.states, ["open"]); + assertEquals(request.tag_ids, ["tag-2"]); + assertEquals( + compileTicketSearch("Don't lose Case", tags).query, + "Don't lose Case", + ); +}); + +Deno.test("active tag names and stable IDs resolve canonically and deduplicate without mutation", () => { + const request = compileTicketSearch( + 'tag:Ready tag:tag-1 tag:"Needs review" tag:tag-2 tag:Ready', + tags, + ); + assertEquals(request.tag_ids, ["tag-1", "tag-2"]); + assertEquals(request.query, null); + request.tag_ids?.push("not-in-catalog"); + assertEquals(compileTicketSearch("tag:Ready", tags).tag_ids, ["tag-1"]); + assertEquals(tags[0].name, "Ready"); +}); + +Deno.test("unknown deleted and case-mismatched tags fail instead of broadening search", () => { + for (const value of ["Missing", "Retired", "tag-3", "ready"]) { + assertSearchError(`tag:${value}`, "tag"); + } + assertSearchError("tag:Ready", "active tag", []); +}); + +Deno.test("ambiguous tag names require a stable ID and stable IDs take precedence", () => { + const catalog = [...tags, { ...tags[0], id: "tag-4" }, { + ...tags[1], + name: "tag-1", + }]; + assertSearchError("tag:Ready", "stable tag ID", catalog); + assertEquals(compileTicketSearch("tag:tag-1", catalog).tag_ids, ["tag-1"]); +}); + +Deno.test("assignee forms compile to generated principal snapshots without altering case", () => { + for ( + const [input, expected] of [ + ["orchestrator", { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }], + ["user/Account-A", { kind: "user", account_id: "Account-A" }], + ["worker/Runtime-A/Worker-B", { + kind: "worker", + runtime_id: "Runtime-A", + worker_id: "Worker-B", + }], + ] as const + ) { + const request = compileTicketSearch(`assignee:${input}`, tags); + assertEquals(request.assigned_principal, expected); + assertEquals(request.query, null); + assertEquals(request.states, []); + } +}); + +Deno.test("malformed assignees reject missing extra or whitespace-separated ID components", () => { + for ( + const input of [ + "user", + "user/", + "user/a/b", + "worker", + "worker/r", + "worker//w", + "worker/r/", + "worker/r/w/extra", + "orchestrator/extra", + "workspace-orchestrator", + '"user/Account A"', + '"worker/ /w"', + ] + ) { + assertSearchError(`assignee:${input}`, "user/"); + } +}); + +Deno.test("built-in reasons remain built-in even when quoted and do not imply closed state", () => { + for ( + const value of ["completed", "not_planned", "duplicate", "unknown"] as const + ) { + for (const input of [`reason:${value}`, `reason:"${value}"`]) { + const request = compileTicketSearch(input, tags); + assertEquals(request.closure_reason, value); + assertEquals(request.states, []); + assertEquals(request.query, null); + } + } +}); + +Deno.test("custom reasons require quotes and preserve exact text", () => { + for ( + const value of ["Superseded Plan", "Completed", "other", " Exact text "] + ) { + assertEquals( + compileTicketSearch(`reason:"${value}"`, tags).closure_reason, + { other: value }, + ); + } + assertSearchError("reason:Superseded", "double-quote custom exact text"); + assertSearchError("reason:other", "double-quote custom exact text"); +}); + +Deno.test("recognized qualifiers reject empty values immediately after the colon", () => { + for (const qualifier of ["state", "tag", "assignee", "reason"]) { + for (const suffix of ["", '""', '" "', " word"]) { + assertSearchError(`${qualifier}:${suffix}`, `Empty ${qualifier}:`); + } + } +}); + +Deno.test("state close and other unrecognized state values are not aliases or text", () => { + for (const value of ["close", "Closed", "OPEN", "done", "*", "open,closed"]) { + assertSearchError(`state:${value}`, "use open, closed, or all"); + } +}); + +Deno.test("conflicting singleton filters fail rather than applying the last value", () => { + for ( + const [input, qualifier] of [ + ["state:open state:closed", "state"], + ["state:all state:open", "state"], + ["assignee:user/a assignee:user/b", "assignee"], + ["assignee:orchestrator assignee:worker/r/w", "assignee"], + ["reason:completed reason:duplicate", "reason"], + ['reason:"Custom" reason:"custom"', "reason"], + ] + ) { + assertSearchError(input, `Conflicting ${qualifier}:`); + } +}); + +Deno.test("equivalent singleton filters may repeat without changing their meaning", () => { + const request = compileTicketSearch( + 'state:closed state:"closed" assignee:user/a assignee:"user/a" reason:completed reason:"completed"', + tags, + ); + assertEquals(request.states, ["closed"]); + assertEquals(request.assigned_principal, { kind: "user", account_id: "a" }); + assertEquals(request.closure_reason, "completed"); +}); + +Deno.test("unterminated quotes report an actionable error in filters and ordinary text", () => { + for ( + const input of [ + 'tag:"Needs review', + 'reason:"Custom', + '"ordinary text', + 'url:"unfinished', + ] + ) { + assertSearchError(input, "add a closing double quote"); + } +}); + +Deno.test("malformed quoted qualifier values fail rather than concatenating fragments", () => { + for ( + const input of [ + 'state:"open"extra', + 'tag:Ready"oops"', + 'reason:"custom""text"', + ] + ) { + assertSearchError(input, "one pair of double quotes"); + } +}); + +Deno.test("mixed search compiles every supported filter and only remaining words", () => { + assertEquals( + compileTicketSearch( + 'Fix state:closed tag:Ready HTTP assignee:worker/Runtime/Worker reason:"Superseded plan"', + tags, + ), + { + query: "Fix HTTP", + states: ["closed"], + tag_ids: ["tag-1"], + assigned_principal: { + kind: "worker", + runtime_id: "Runtime", + worker_id: "Worker", + }, + handling_status: null, + sort: "updated_desc", + limit: 30, + cursor: null, + closure_reason: { other: "Superseded plan" }, + }, + ); +}); diff --git a/web/workspace/src/lib/workspace/tickets/ticket-search.ts b/web/workspace/src/lib/workspace/tickets/ticket-search.ts new file mode 100644 index 000000000..ea86f4c76 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/ticket-search.ts @@ -0,0 +1,178 @@ +import type { + TicketAssignmentPrincipalSnapshot, + TicketClosureReason, + TicketQueryRequest, + WorkspaceTag, +} from "../../generated/ticket-api.ts"; + +export const DEFAULT_TICKET_SEARCH = "state:open"; + +function tokens(input: string): string[] { + const result: string[] = []; + let start = 0; + let quoteStart: number | null = null; + for (let index = 0; index <= input.length; index++) { + if (input[index] === '"') { + quoteStart = quoteStart === null ? index : null; + } + if ( + index === input.length || (quoteStart === null && /\s/.test(input[index])) + ) { + if (index > start) result.push(input.slice(start, index)); + start = index + 1; + } + } + if (quoteStart !== null) { + throw new Error( + `Unterminated quote at column ${ + quoteStart + 1 + }; add a closing double quote.`, + ); + } + return result; +} + +export function ticketSearchNeedsTags(input: string): boolean { + return tokens(input).some((token) => token.startsWith("tag:")); +} + +function qualifierValue( + raw: string, + qualifier: string, +): { value: string; quoted: boolean } { + const quoted = raw.startsWith('"'); + const value = quoted && raw.endsWith('"') ? raw.slice(1, -1) : raw; + if (value.includes('"')) { + throw new Error( + `Invalid ${qualifier}: value; put the entire value inside one pair of double quotes.`, + ); + } + if (!value.trim()) { + throw new Error( + `Empty ${qualifier}: value; provide a value immediately after the colon.`, + ); + } + return { value, quoted }; +} + +function tagId(value: string, tags: readonly WorkspaceTag[]): string { + const byId = tags.find((tag) => tag.id === value); + if (byId) { + if (byId.deleted) { + throw new Error(`Tag "${value}" is deleted; choose an active tag.`); + } + return byId.id; + } + const matches = tags.filter((tag) => !tag.deleted && tag.name === value); + const ids = [...new Set(matches.map((tag) => tag.id))]; + if (ids.length > 1) { + throw new Error(`Tag name "${value}" is ambiguous; use its stable tag ID.`); + } + if (ids.length === 0) { + throw new Error( + `Unknown or deleted tag "${value}"; use an active tag name or stable ID.`, + ); + } + return ids[0]; +} + +function principal(value: string): TicketAssignmentPrincipalSnapshot { + if (value === "orchestrator") { + return { kind: "workspace_agent", agent_key: "workspace-orchestrator" }; + } + const parts = value.split("/"); + if (parts.every((part) => part.length > 0 && !/\s/.test(part))) { + if (parts[0] === "user" && parts.length === 2) { + return { kind: "user", account_id: parts[1] }; + } + if (parts[0] === "worker" && parts.length === 3) { + return { kind: "worker", runtime_id: parts[1], worker_id: parts[2] }; + } + } + throw new Error( + "Invalid assignee: value; use orchestrator, user/, or worker//.", + ); +} + +function reason(value: string, quoted: boolean): TicketClosureReason { + // 引用符は組込み理由をcustomへ変換しない。非組込みの完全一致テキストだけをotherへ送る。 + switch (value) { + case "completed": + case "not_planned": + case "duplicate": + case "unknown": + return value; + default: + if (quoted) return { other: value }; + throw new Error( + "Invalid reason: value; use completed, not_planned, duplicate, unknown, or double-quote custom exact text.", + ); + } +} + +/** 既定検索の適用はloaderが担い、空入力やstate省略では全stateを検索する。 */ +export function compileTicketSearch( + input: string, + tags: readonly WorkspaceTag[], +): TicketQueryRequest { + const request: TicketQueryRequest = { + query: null, + states: [], + tag_ids: [], + assigned_principal: null, + handling_status: null, + sort: "updated_desc", + limit: 30 as TicketQueryRequest["limit"], + cursor: null, + }; + const text: string[] = []; + const tagIds = new Set(); + const singletons = new Map(); + for (const token of tokens(input)) { + const match = /^(state|tag|assignee|reason):(.*)$/s.exec(token); + if (!match) { + // 未知のqualifierやURLはそのまま残す。引用された通常文もfilterとして解釈しない。 + text.push( + token.startsWith('"') && token.endsWith('"') + ? token.slice(1, -1) + : token, + ); + continue; + } + const [, qualifier, raw] = match; + const { value, quoted } = qualifierValue(raw, qualifier); + if (qualifier === "tag") { + tagIds.add(tagId(value, tags)); + continue; + } + let canonical: string; + switch (qualifier) { + case "state": + if (value !== "open" && value !== "closed" && value !== "all") { + throw new Error( + "Invalid state: value; use open, closed, or all (not close).", + ); + } + canonical = value; + request.states = value === "all" ? [] : [value]; + break; + case "assignee": + request.assigned_principal = principal(value); + canonical = JSON.stringify(request.assigned_principal); + break; + default: + request.closure_reason = reason(value, quoted); + canonical = JSON.stringify(request.closure_reason); + break; + } + if (singletons.has(qualifier) && singletons.get(qualifier) !== canonical) { + throw new Error( + `Conflicting ${qualifier}: values; keep only one ${qualifier}: filter.`, + ); + } + singletons.set(qualifier, canonical); + } + request.query = text.join(" ").trim() || null; + request.tag_ids = [...tagIds]; + return request; +} diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index d653e25f6..b9816efde 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -1,6 +1,6 @@