feat: replace Ticket kanban with query-based list

This commit is contained in:
2026-10-13 19:08:51 +09:00
parent 12d1588c05
commit a82e70047a
13 changed files with 1137 additions and 632 deletions
+2 -2
View File
@@ -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へ適用してからページを取得する。
+286 -115
View File
@@ -8,8 +8,7 @@ import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
async function withFixture(
run: (browser: Browser, baseUrl: string) => Promise<void>,
) {
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<void>) {
let timer: number | undefined;
try {
await Promise.race([
promise,
new Promise<never>((_, 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<void>();
const release = Promise.withResolvers<void>();
const finished = Promise.withResolvers<void>();
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,
);
}
+74 -23
View File
@@ -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": [
+74 -23
View File
@@ -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": [
+1 -1
View File
@@ -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",
@@ -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(
@@ -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);
@@ -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>): void;
@@ -29,57 +23,6 @@ function assertEquals<T>(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.",
@@ -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<TicketQueryItem, "tags" | "current_closure" | "assignment_tracking">
>;
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,
};
}
@@ -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<T>(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<unknown>(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/<account-id>");
}
});
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<unknown>(
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" },
},
);
});
@@ -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/<account-id>, or worker/<runtime-id>/<worker-id>.",
);
}
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<string>();
const singletons = new Map<string, string>();
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;
}
@@ -1,6 +1,6 @@
<script lang="ts">
// laneは案件状態のopen/closedだけを表し、狭い画面では縦に並べる。
// 担当の自己申告handlingと分類タグは別に示し、laneやWorker稼働状態へ読み替えない。
// 検索条件とページ順序はBackendのqueryに任せ、取得済みの一部を並べ替えない。
// 案件状態と担当の自己申告は、同じ行でも別の情報として表示する。
import { goto } from "$app/navigation";
import { untrack } from "svelte";
import type { ApiResult } from "#lib/workspace/api/http.ts";
@@ -18,27 +18,17 @@
} from "#lib/workspace/api/ticket-browser.ts";
import type {
NewTicket,
QueryPage,
TicketQueryItem as TicketSummary,
TicketState,
TicketTarget,
TicketTargetAccess,
} from "#lib/generated/ticket-api.ts";
import { ticketHref } from "#lib/workspace/resource-links.ts";
import {
ticketQueryRequest, currentOrchestratorAssignment, assignmentPrincipalLabel, handlingLabel, handlingSummary, closureReasonLabel,
currentOrchestratorAssignment, assignmentPrincipalLabel, handlingLabel, closureReasonLabel,
type WorkspaceOrchestratorStatus,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type { PageData } from "./$types";
type LaneState = {
states: TicketState[];
tickets: TicketSummary[];
page: QueryPage | null;
loading: boolean;
error: string | null;
};
type EditableTicketTarget = {
repository_key: string;
ref_selector: string;
@@ -46,19 +36,21 @@
};
let { data }: { data: PageData } = $props();
function initialLanes(): Record<string, LaneState> {
return Object.fromEntries(Object.entries(data.ticketLanes).flatMap(([laneId, result]) => result ? [[laneId, {
states: [laneId as TicketState], tickets: result.data?.items ?? [], page: result.data?.page ?? null,
loading: false, error: result.error,
}]] : []));
}
let laneState = $state<Record<string, LaneState>>(untrack(initialLanes));
let principal = $state(untrack(() => data.filters.principal));
let closureChoice = $state(untrack(() => data.filters.closureReason));
let filtersBusy = $state(false);
$effect(() => { const currentData = data; untrack(() => {
laneState = initialLanes(); principal = currentData.filters.principal; closureChoice = currentData.filters.closureReason; orchestrator = currentData.orchestrator;
}); });
let response = $state(untrack(() => data.tickets));
let searchText = $state(untrack(() => data.search));
let searchBusy = $state(false);
let pageBusy = $state(false);
let navigationError = $state<string | null>(null);
$effect(() => {
const currentData = data;
untrack(() => {
response = currentData.tickets;
searchText = currentData.search;
pageBusy = false;
navigationError = null;
orchestrator = currentData.orchestrator;
});
});
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
untrack(() => data.orchestrator),
);
@@ -82,15 +74,21 @@
}
return true;
});
const lanes = $derived(Object.entries(laneState).map(([id, lane]) => ({ id, label: id === "open" ? "Open" : "Closed", tickets: lane.tickets })));
async function applyFilters(event: SubmitEvent) {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const search = new URLSearchParams();
for (const [key, value] of new FormData(form)) if (typeof value === "string" && value) search.append(key, value);
filtersBusy = true;
try { await goto(`?${search}`); } finally { filtersBusy = false; }
async function searchTickets(event?: SubmitEvent) {
event?.preventDefault();
if (searchBusy) return;
searchBusy = true;
navigationError = null;
try {
const query = new URLSearchParams({ q: searchText.trim() });
await goto(`/w/${encodeURIComponent(data.workspaceId)}/tickets?${query}`, {
reset: false, refreshAll: true,
});
} catch (error) {
navigationError = error instanceof Error ? error.message : String(error);
} finally {
searchBusy = false;
}
}
function mergeTickets(
@@ -102,31 +100,28 @@
return [...byId.values()];
}
async function loadMore(laneId: string): Promise<void> {
const lane = laneState[laneId];
if (!lane || lane.loading || (lane.page && !lane.page.has_more)) {
return;
}
lane.loading = true;
lane.error = null;
try {
const result = await loadJson(
fetch,
workspaceApiPath(data.workspaceId, "/tickets/query"),
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(ticketQueryRequest(data.filters, lane.states, lane.page?.next_cursor ?? null)) },
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
if (!result.data) {
throw new Error(result.error ?? "追加読み込みに失敗しました");
}
lane.tickets = mergeTickets(lane.tickets, result.data.items);
lane.page = result.data.page;
} catch (error) {
lane.error = error instanceof Error ? error.message : String(error);
} finally {
lane.loading = false;
}
async function loadMore(): Promise<void> {
if (pageBusy || searchBusy || !data.queryRequest || (response.data && !response.data.page.has_more)) return;
const observedData = data;
const previous = response.data;
pageBusy = true;
response = { data: previous, error: null };
const result = await loadJson(
fetch,
workspaceApiPath(observedData.workspaceId, "/tickets/query"),
{
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ...observedData.queryRequest, cursor: previous?.page.next_cursor ?? null }),
},
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
// 旧query/Workspaceの遅延応答で、現在の一覧やページ状態を上書きしない。
if (data !== observedData) return;
response = result.data
? { data: { ...result.data, items: mergeTickets(previous?.items ?? [], result.data.items) }, error: null }
: { data: previous, error: result.error };
pageBusy = false;
}
function addCreateTarget(): void {
@@ -288,98 +283,91 @@
</p>
{/if}
<form class="ticket-query-form" onsubmit={applyFilters} aria-label="Ticket filters">
<label>Search<input name="q" value={data.filters.query} type="search" /></label>
<label>Case<select name="case" value={data.filters.caseState}><option value="all">Open and closed</option><option value="open">Open</option><option value="closed">Closed</option></select></label>
<label>Assigned principal<select name="principal" bind:value={principal}><option value="all">Any</option><option value="orchestrator">Orchestrator</option><option value="user">User</option><option value="worker">Worker</option></select></label>
{#if principal === "user"}<label>Account ID<input name="account" value={data.filters.accountId} required /></label>{/if}
{#if principal === "worker"}<label>Runtime ID<input name="runtime" value={data.filters.runtimeId} required /></label><label>Worker ID<input name="worker" value={data.filters.workerId} required /></label>{/if}
<fieldset><legend>Classification tags (all selected)</legend>
{#each data.tags.data ?? [] as tag (tag.id)}
{#if !tag.deleted}<label class="ticket-tag-filter"><input type="checkbox" name="tag" value={tag.id} checked={data.filters.tagIds.includes(tag.id)} />{tag.name}</label>{/if}
{:else}<span class="workspace-empty-copy">No tags</span>{/each}
{#if data.tags.error}<p class="workspace-callout is-error" role="alert">Tags: {data.tags.error}</p>{/if}
</fieldset>
<details class="ticket-reason-filter" open={data.filters.closureReason !== "all"}><summary>Close reason filter</summary>
<label>Current close reason<select name="reason" bind:value={closureChoice}><option value="all">Any reason</option><option value="completed">Completed</option><option value="not_planned">Not planned</option><option value="duplicate">Duplicate</option><option value="unknown">Unknown</option><option value="other">Other (exact text)</option></select></label>
{#if closureChoice === "other"}<label>Close reason text<input name="reason_text" value={data.filters.closureText} required /></label>{/if}
<p class="workspace-empty-copy">Matches current closed cases. Search also includes past close reasons.</p>
</details>
<button class="workspace-secondary-button" disabled={filtersBusy}>{filtersBusy ? "Searching…" : "Apply filters"}</button>
<a href={`/w/${encodeURIComponent(data.workspaceId)}/orchestrator`}>Orchestrator handling</a>
<form class="ticket-query-form" onsubmit={searchTickets} aria-label="Search tickets">
<input name="q" type="search" aria-label="Search tickets" bind:value={searchText}
placeholder="Search tickets · state:open" spellcheck="false" />
<button class="workspace-secondary-button" type="submit" disabled={searchBusy}>
{searchBusy ? "Searching…" : "Search"}
</button>
</form>
<div class="ticket-search-context">
<details class="ticket-search-help">
<summary>Search syntax</summary>
<p>Combine text with <code>state:open</code> or <code>state:closed</code>. Omit state to search both.</p>
<p><code>tag:"UI bug"</code> · <code>assignee:orchestrator</code> · <code>reason:completed</code></p>
<p>Other principals: <code>assignee:user/account-id</code> or <code>assignee:worker/runtime-id/worker-id</code>.
Custom close reason: <code>reason:"Superseded plan"</code>.</p>
</details>
<a href={'/w/' + encodeURIComponent(data.workspaceId) + '/orchestrator'}>Orchestrator handling</a>
</div>
{#if navigationError}<p class="workspace-callout is-error" role="alert">{navigationError}</p>{/if}
<section class="ticket-kanban" aria-label="Ticket cases">
{#each lanes as lane (lane.id)}
{@const pagination = laneState[lane.id]}
<section class="ticket-lane" data-state={lane.id}>
<header class="ticket-lane-header">
<div>
<span class="ticket-state-dot"></span>
<h2>{lane.label}</h2>
</div>
<span class="ticket-lane-count">{lane.tickets.length} loaded</span>
</header>
<div
class="ticket-lane-cards"
data-lane-id={lane.id}
>
{#each lane.tickets as ticket (ticket.id)}
{@const assignment = currentOrchestratorAssignment(ticket)}
<a
class="ticket-card"
href={ticketHref(data.workspaceId, ticket)}
>
<span class="ticket-card-id">{ticket.resource_key}</span>
<strong>{ticket.title}</strong>
<div class="ticket-card-meta">
<span>Case: {ticket.state} · {ticket.priority}</span>
<time>{prettyDate(ticket.updated_at)}</time>
<section class="ticket-list" aria-label="Tickets" aria-busy={searchBusy || pageBusy}>
{#if response.data}
<p class="ticket-list-count" aria-live="polite">{response.data.items.length} tickets loaded</p>
{/if}
<ul class="ticket-list-rows">
{#each response.data?.items ?? [] as ticket (ticket.id)}
{@const assignment = currentOrchestratorAssignment(ticket)}
<li>
<a class="ticket-list-row" data-state={ticket.state} href={ticketHref(data.workspaceId, ticket)}>
<span class="ticket-list-state">{ticket.state === "open" ? "Open" : "Closed"}</span>
<div class="ticket-list-content">
<strong class="ticket-list-title">{ticket.title}</strong>
<div class="ticket-list-meta">
<span class="ticket-list-key">{ticket.resource_key}</span>
<time datetime={ticket.updated_at ?? undefined}>{prettyDate(ticket.updated_at)}</time>
{#each ticket.tags as tag (tag.id)}<span class="ticket-list-tag">{tag.name}</span>{/each}
{#if ticket.current_closure}<span>Close reason: {closureReasonLabel(ticket.current_closure.reason)}</span>{/if}
{#if assignment}<span title={assignmentPrincipalLabel(assignment.principal)}>Handling: {handlingLabel(assignment.tracking?.status)} ({assignment.tracking ? "self-reported" : "not reported"})</span>{/if}
</div>
{#if ticket.current_closure}<span class="ticket-closure-summary">Close reason: {closureReasonLabel(ticket.current_closure.reason)}</span>{/if}
<span class="ticket-classification">Tags: {ticket.tags.length ? ticket.tags.map((tag) => tag.name).join(", ") : "None"}</span>
<div class="ticket-handling-summary">
<span>Orchestrator handling: {assignment ? handlingLabel(assignment.tracking?.status) : "Not assigned"}{#if assignment}{" "}<small>({assignment.tracking ? "self-reported" : "not reported"})</small>{/if}</span>
{#if assignment}<span>Assigned principal: {assignmentPrincipalLabel(assignment.principal)}</span>{/if}
<span>{handlingSummary(assignment)}</span>
</div>
</a>
{:else}
{#if !pagination?.error}<div class="ticket-lane-empty">No tickets</div>{/if}
{/each}
{#if pagination?.loading}
<p class="ticket-lane-page-state" aria-live="polite">Loading…</p>
{:else if pagination?.error}
<div class="ticket-lane-page-state ticket-lane-page-error" role="alert">
<span>{pagination.error}</span>
<button type="button" onclick={() => loadMore(lane.id)}>Retry</button>
</div>
{:else if pagination?.page?.has_more}
<button class="workspace-secondary-button" type="button" onclick={() => loadMore(lane.id)}>Load more {lane.label.toLowerCase()} tickets</button>
{:else if pagination?.page && !pagination.page.has_more && lane.tickets.length > 0}
<p class="ticket-lane-page-state">End of lane</p>
{/if}
</div>
</section>
{/each}
</a>
</li>
{/each}
</ul>
{#if response.error}
<div class="ticket-list-error" role="alert">
<span>{response.error}</span>
<button class="workspace-secondary-button" type="button" disabled={pageBusy || searchBusy}
onclick={() => data.queryRequest ? loadMore() : searchTickets()}>Retry</button>
</div>
{:else if !response.data?.items.length && !searchBusy}
<p class="ticket-list-empty">No tickets match this query.</p>
{/if}
{#if pageBusy}
<p class="ticket-list-page-state" aria-live="polite">Loading…</p>
{:else if response.data?.page.has_more && !response.error}
<button class="workspace-secondary-button ticket-load-more" type="button" disabled={searchBusy} onclick={loadMore}>Load more tickets</button>
{/if}
</section>
</div>
<style>
.ticket-query-form { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin-block: var(--space-4); }
.ticket-query-form > label { display: grid; gap: var(--space-1); min-width: 0; flex: 1 1 10rem; }
.ticket-query-form input:not([type="checkbox"]), .ticket-query-form select { min-width: 0; width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-soft); font: inherit; }
.ticket-query-form fieldset { display: flex; flex-wrap: wrap; gap: var(--space-3); border: 0; padding: 0; flex: 1 1 100%; min-width: 0; }
.ticket-tag-filter { display: flex; align-items: center; gap: var(--space-1); overflow-wrap: anywhere; }
.ticket-reason-filter { flex: 1 1 100%; }
.ticket-reason-filter label { display: grid; gap: var(--space-1); max-width: 28rem; margin-block: var(--space-2); }
.ticket-reason-filter summary { cursor: pointer; }
.ticket-query-form legend { margin-bottom: var(--space-2); }
.ticket-kanban { grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); align-items: start; overflow: visible; }
.ticket-lane { height: auto; min-height: 0; max-height: none; }
.ticket-lane-cards { max-height: none; overflow: visible; }
.ticket-card { overflow-wrap: anywhere; }
.ticket-handling-summary { display: grid; gap: var(--space-1); border-top: 1px solid var(--line); padding-top: var(--space-2); color: var(--text-muted); }
.ticket-classification { color: var(--text-muted); }
.ticket-closure-summary { font-weight: 600; }
.ticket-query-form { display: flex; align-items: stretch; gap: var(--space-2); margin-top: var(--space-4); }
.ticket-query-form input { flex: 1; min-width: 0; padding: var(--space-2) var(--space-3); color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius-soft); font: inherit; }
.ticket-query-form input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.ticket-search-context { display: flex; align-items: start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2); margin-block: var(--space-2) var(--space-4); color: var(--text-muted); font-size: var(--font-size-compact); }
.ticket-search-context a { color: inherit; }
.ticket-search-help { min-width: 0; max-width: 100%; }
.ticket-search-help summary { cursor: pointer; width: fit-content; }
.ticket-search-help p { margin-block: var(--space-2); overflow-wrap: anywhere; }
.ticket-search-help code { font-size: inherit; }
.ticket-list { min-width: 0; }
.ticket-list-count { margin: 0; padding-block: var(--space-2); color: var(--text-muted); font-size: var(--font-size-compact); }
.ticket-list-rows { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.ticket-list-row { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); align-items: start; gap: var(--space-2); padding-block: var(--space-3); border-bottom: 1px solid var(--line); color: var(--text); text-decoration: none; }
.ticket-list-row:hover { background: var(--interactive-hover); }
.ticket-list-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.ticket-list-state { padding-top: 2px; font-size: var(--font-size-compact); color: var(--text-muted); }
.ticket-list-row[data-state="open"] .ticket-list-state { color: var(--success); }
.ticket-list-content { min-width: 0; }
.ticket-list-title { display: block; font-size: var(--font-size-body); line-height: var(--line-height-body); color: var(--text-strong); overflow-wrap: anywhere; }
.ticket-list-meta { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); margin-top: var(--space-1); font-size: var(--font-size-compact); color: var(--text-muted); overflow-wrap: anywhere; }
.ticket-list-key { font-family: var(--font-mono); }
.ticket-list-tag { color: var(--text); }
.ticket-list-empty, .ticket-list-page-state { padding-block: var(--space-4); margin: 0; color: var(--text-muted); }
.ticket-list-error { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); padding-block: var(--space-4); color: var(--danger); overflow-wrap: anywhere; }
.ticket-load-more { margin-block: var(--space-4); }
@media (max-width: 480px) { .ticket-list-row { grid-template-columns: 3.5rem minmax(0, 1fr); } }
</style>
@@ -7,46 +7,74 @@ import {
} from "#lib/workspace/api/ticket-browser.ts";
import { parseTags } from "#lib/workspace/api/ticket-operations.ts";
import {
readTicketFilters,
ticketQueryRequest,
} from "#lib/workspace/tickets/ticket-panel.ts";
import type { TicketState } from "#lib/generated/ticket-api.ts";
compileTicketSearch,
DEFAULT_TICKET_SEARCH,
ticketSearchNeedsTags,
} from "#lib/workspace/tickets/ticket-search.ts";
import type {
TicketQueryRequest,
WorkspaceTag,
} from "#lib/generated/ticket-api.ts";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params, url }) => {
const workspaceId = params.workspaceId;
const filters = readTicketFilters(url.searchParams);
const loadLane = async (state: TicketState) => {
if (filters.caseState !== "all" && filters.caseState !== state) return null;
const search = url.searchParams.get("q") ?? DEFAULT_TICKET_SEARCH;
const loadTickets = async () => {
let queryRequest: TicketQueryRequest | null = null;
try {
return await loadJson(
const tags: WorkspaceTag[] = [];
// タグ名をstable IDへ解決する場合だけ一覧を取得する。状態検索をタグAPIに依存させない。
if (ticketSearchNeedsTags(search)) {
let after = "";
while (true) {
const result = await loadJson(
fetch,
workspaceApiPath(
workspaceId,
`/ticket-tags?limit=200${
after ? `&after=${encodeURIComponent(after)}` : ""
}`,
),
undefined,
parseTags,
TICKET_BROWSER_API_LOAD_POLICY,
);
if (!result.data) {
throw new Error(result.error ?? "Tags could not be loaded");
}
tags.push(...result.data);
if (result.data.length < 200) break;
const next = result.data.at(-1)!.id;
if (next <= after) throw new Error("Tag pagination did not advance");
after = next;
}
}
queryRequest = compileTicketSearch(search, tags);
const tickets = await loadJson(
fetch,
workspaceApiPath(workspaceId, "/tickets/query"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ticketQueryRequest(filters, [state])),
body: JSON.stringify(queryRequest),
},
parseTicketQueryResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
return { queryRequest, tickets };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error.message : String(error),
queryRequest,
tickets: {
data: null,
error: error instanceof Error ? error.message : String(error),
},
};
}
};
const [open, closed, tags, orchestrator, repositories] = await Promise.all([
loadLane("open"),
loadLane("closed"),
loadJson(
fetch,
workspaceApiPath(workspaceId, "/ticket-tags"),
undefined,
parseTags,
TICKET_BROWSER_API_LOAD_POLICY,
),
const [result, orchestrator, repositories] = await Promise.all([
loadTickets(),
loadJson(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
@@ -55,12 +83,5 @@ export const load: PageLoad = async ({ fetch, params, url }) => {
),
loadWorkspaceRepositoryList(fetch, workspaceId),
]);
return {
workspaceId,
filters,
ticketLanes: { open, closed },
tags,
orchestrator,
repositories,
};
return { workspaceId, search, ...result, orchestrator, repositories };
};