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
+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 };
};