feat: unify Ticket posts and simplify detail layout

Checkpoint requested before the targets read-tool correction. Includes canonical initial posts, creator/edit metadata, migration and UI work, and removal of delivery diagnostics from Ticket details.\n\nIntegration remains incomplete: the Client post-edit trait implementation and server post routes/dispatch are not yet connected; cargo check currently fails. This commit does not assert validation or delivery completion.
This commit is contained in:
2026-10-14 02:42:58 +09:00
parent 26ab6130e0
commit 8611078058
45 changed files with 5862 additions and 668 deletions
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// Input contract: 0.1.0 (sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// Input contract: 0.1.0 (sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -137,7 +137,7 @@ export type TicketClosureJudgment = { at: string; author: string; explanation: s
export type TicketClosureReason = "completed" | "not_planned" | "duplicate" | "unknown" | { other: string; };
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignment_tracking: Array<TicketAssignmentTrackingProjection>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; closure_history: Array<TicketCaseHistoryEntry>; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: BrowserTicketState; tags: Array<WorkspaceTag>; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignment_tracking: Array<TicketAssignmentTrackingProjection>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; closure_history: Array<TicketCaseHistoryEntry>; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; created_by?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; initial_post: TicketEventDetail; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: BrowserTicketState; tags: Array<WorkspaceTag>; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
@@ -149,7 +149,7 @@ export type TicketDetailRelationNotice = { kind: string; message: string; relate
export type TicketDetailRelationView = { blockers: Array<TicketDetailRelationBlocker>; incoming: Array<TicketDetailDerivedRelation>; notices: Array<TicketDetailRelationNotice>; outgoing: Array<TicketDetailRelation>; };
export type TicketEventDetail = { at?: string | null; attributes: { [key: string]: string; }; author?: string | null; body?: string | null; event_ref: string; from?: string | null; heading?: string | null; kind: string; reason?: string | null; references: Array<string>; sequence: number; state_field?: string | null; status?: string | null; to?: string | null; };
export type TicketEventDetail = { at?: string | null; attributes: { [key: string]: string; }; author?: string | null; body?: string | null; body_digest: string; body_truncated: boolean; editable: boolean; edited_at?: string | null; edited_by?: string | null; event_ref: string; from?: string | null; heading?: string | null; is_initial: boolean; kind: string; reason?: string | null; references: Array<string>; sequence: number; state_field?: string | null; status?: string | null; to?: string | null; };
export type TicketEvidenceEvent = { at?: string | null; author?: string | null; event_ref: string; excerpt: string; kind: string; sequence: number; };
@@ -167,6 +167,8 @@ export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | nu
export type TicketMergeRequestSummary = { current_subject_ref?: string | null; integration_evidence_error?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; source_ref_observation: TicketSourceRefObservation; state: string; updated_at: string; };
export type TicketPostEditRequest = { body: string; expected_body_digest: string; };
export type TicketQueryItem = { assignment_tracking: Array<TicketAssignmentTrackingProjection>; blocker_count: number; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objective_ids: Array<string>; linked_objective_keys: Array<string>; matched_fields: Array<string>; matching_event?: TicketEvidenceEvent | null; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; readiness?: string | null; relation_count: number; resource_key: string; snippet?: string | null; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; unresolved_blocker_count: number; unresolved_review_count: number; updated_at?: string | null; workspace_action_priority: string; };
export type TicketQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; attention?: Array<TicketAttention>; closure_reason?: TicketClosureReason | null; cursor?: string | null; event_kinds?: Array<string>; evidence?: Array<string>; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; tag_ids_any?: Array<string>; tag_ids_none?: Array<string>; updated_after?: string | null; updated_before?: string | null; };
@@ -2,7 +2,9 @@ declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
};
import { fixturePost } from "../home/dashboard.test-fixtures.ts";
import {
parseTicketEvent,
parseMergeRequestDetailResponse,
parseMergeRequestListResponse,
parseObjectiveListResponse,
@@ -41,6 +43,33 @@ const page = {
source_truncated: false,
};
Deno.test("Ticket post parser preserves author and edit metadata independently of pagination", () => {
const initial = fixturePost("Current initial post", { edited_at: "2026-10-13T10:00:00Z", edited_by: "Rin" });
const parsed = parseTicketDetail({ ...ticketDetailFixture(), initial_post: initial, events: [], event_page: { ...page, returned: 0, has_more: true } });
assertEquals(parsed.initial_post.author, initial.author);
assertEquals(parsed.initial_post.at, initial.at);
assertEquals(parsed.initial_post.edited_at, initial.edited_at);
assertEquals(parsed.initial_post.edited_by, "Rin");
assertEquals(parsed.initial_post.body_digest, initial.body_digest);
assertEquals(parsed.initial_post.is_initial, true);
assertEquals(parsed.created_by, null);
assertEquals(parsed.events, []);
});
Deno.test("Ticket post parser rejects absent safety fields and unknown wire fields", () => {
for (const field of ["is_initial", "body_digest", "body_truncated", "editable"]) {
const wire: Record<string, unknown> = { ...fixturePost("Post") };
delete wire[field];
assertThrows(() => parseTicketEvent(wire), field);
}
assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), editable: "yes" }), "editable");
assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), edited_by: 7 }), "edited_by");
assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), surprise: true }), "unknown field surprise");
const detail: Record<string, unknown> = { ...ticketDetailFixture() };
delete detail.initial_post;
assertThrows(() => parseTicketDetail(detail), "initial_post");
});
Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () => {
const parsed = parseTicketListResponse({
workspace_id: "workspace-a",
@@ -145,6 +174,8 @@ function ticketDetailFixture() {
closure_operation_key: null,
current_closure: null,
legacy_handoff: null,
created_by: null,
initial_post: fixturePost("Body"),
body: "Body",
body_truncated: false,
current_worker: null,
@@ -495,9 +495,15 @@ export function parseTicketListResponse(value: unknown): TicketListResponse {
};
}
function parseTicketEvent(value: unknown, label: string): TicketEventDetail {
export function parseTicketEvent(value: unknown, label = "Ticket post"): TicketEventDetail {
const item = object(value, label);
exact(item, [
"is_initial",
"edited_at",
"edited_by",
"body_digest",
"body_truncated",
"editable",
"at",
"attributes",
"author",
@@ -514,6 +520,12 @@ function parseTicketEvent(value: unknown, label: string): TicketEventDetail {
"to",
], label);
return {
is_initial: boolean(item.is_initial, `${label}.is_initial`),
edited_at: optionalNullableString(item.edited_at, `${label}.edited_at`),
edited_by: optionalNullableString(item.edited_by, `${label}.edited_by`),
body_digest: string(item.body_digest, `${label}.body_digest`),
body_truncated: boolean(item.body_truncated, `${label}.body_truncated`),
editable: boolean(item.editable, `${label}.editable`),
at: optionalNullableString(item.at, `${label}.at`),
attributes: stringMap(item.attributes, `${label}.attributes`),
author: optionalNullableString(item.author, `${label}.author`),
@@ -1098,6 +1110,8 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"closure_history",
"body",
"body_truncated",
"created_by",
"initial_post",
"created_at",
"current_worker",
"event_count",
@@ -1157,6 +1171,8 @@ export function parseTicketDetail(value: unknown): TicketDetail {
),
body: string(item.body, `${label}.body`),
body_truncated: boolean(item.body_truncated, `${label}.body_truncated`),
created_by: optionalNullableString(item.created_by, `${label}.created_by`),
initial_post: parseTicketEvent(item.initial_post, `${label}.initial_post`),
created_at: optionalNullableString(item.created_at, `${label}.created_at`),
current_worker: item.current_worker === undefined
? undefined
@@ -1,3 +1,15 @@
import type { TicketEventDetail } from "../../generated/ticket-api.ts";
export function fixturePost(body: string, overrides: Partial<TicketEventDetail> = {}): TicketEventDetail {
return {
sequence: 0, event_ref: "post-initial", kind: "comment", is_initial: true,
author: "Mika", at: "2026-09-28T01:00:00Z", body,
edited_at: null, edited_by: null, body_digest: "b".repeat(64),
body_truncated: false, editable: true, attributes: {}, references: [],
...overrides,
};
}
// Deterministic read-only fixtures shared by Home component and browser tests.
export const fixturePage = (returned: number, more = false) => ({
returned,
@@ -42,6 +54,8 @@ export function fixtureDetail(key = "T-101") {
closure_history: [],
content_digest: "a".repeat(64),
created_at: updated(1),
created_by: "Mika",
initial_post: fixturePost("Implementation in progress."),
readiness: null,
body: "Implementation in progress.",
body_truncated: false,
@@ -120,6 +120,7 @@
gap: var(--space-4);
max-width: 88rem;
}
.ticket-detail-header > button { flex-shrink: 0; }
.ticket-detail-header h1 {
margin: 0.45rem 0 0.25rem;
color: var(--text-strong);
@@ -173,6 +174,23 @@
background: var(--bg-raised);
padding: var(--space-4);
}
.ticket-detail-page > .ticket-detail-grid {
grid-template-columns: minmax(0, 1fr) 1px minmax(18rem, 21rem);
gap: var(--space-5);
}
.ticket-header-divider { margin-block: var(--space-4); }
.ticket-column-divider { align-self: stretch; }
.ticket-mobile-sidebar-divider { display: none; }
.ticket-detail-page :is(.ticket-detail-section, .ticket-control-card, .ticket-editor, .workspace-callout) {
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
}
.ticket-detail-page :is(.ticket-detail-main, .ticket-control-rail) {
gap: var(--space-3);
overflow-wrap: anywhere;
}
.ticket-section-heading, .ticket-control-card > header {
justify-content: space-between;
gap: var(--space-3);
@@ -269,14 +287,22 @@
.ticket-relations-list a, .ticket-blocker-list a {
display: grid;
gap: 0.18rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
color: var(--text-strong);
padding: 0.6rem;
padding-block: var(--space-2);
text-decoration: none;
}
.ticket-blocker-list a {
border-color: var(--warning);
.ticket-relations-list a:hover, .ticket-blocker-list a:hover {
background: var(--interactive-hover);
}
.ticket-relations-list a:focus-visible, .ticket-blocker-list a:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ticket-relations-list a:active, .ticket-blocker-list a:active {
background: var(--interactive-selected);
}
.ticket-blocker-list strong {
color: var(--warning);
}
.ticket-relations-list span,
.ticket-relations-list small,
@@ -284,56 +310,68 @@
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.ticket-timeline {
display: grid;
.ticket-thread-composer {
margin-top: 0;
min-width: 0;
}
.ticket-timeline article {
display: grid;
grid-template-columns: 0.65rem minmax(0, 1fr);
gap: 0.75rem;
min-height: 4rem;
}
.ticket-timeline-marker {
position: relative;
margin-top: 0.35rem;
width: 0.55rem;
height: 0.55rem;
border: 2px solid var(--accent);
border-radius: 50%;
}
.ticket-timeline-marker::after {
position: absolute;
top: 0.55rem;
bottom: -3.3rem;
left: 0.15rem;
width: 1px;
background: var(--line);
content: "";
}
.ticket-timeline article:last-child .ticket-timeline-marker::after {
display: none;
}
.ticket-timeline header {
.ticket-comment-actions {
display: flex;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
gap: var(--space-2);
}
.ticket-thread-composer details summary {
cursor: pointer;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.ticket-thread-composer details[open] > :not(summary) {
margin-top: var(--space-2);
}
.ticket-thread { min-width: 0; }
.ticket-timeline { display: grid; }
.ticket-timeline > article { min-width: 0; padding-block: var(--space-5); }
.ticket-timeline > article:first-child { padding-top: 0; }
.ticket-timeline header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.ticket-timeline time, .ticket-event-author {
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.ticket-event-author {
margin: 0.2rem 0;
.ticket-post-attribution { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); min-width: 0; overflow-wrap: anywhere; }
.ticket-post-edited { color: var(--text-muted); font-size: var(--font-size-compact); }
.ticket-metadata dl { display: grid; gap: var(--space-1); margin: 0 0 var(--space-3); }
.ticket-metadata dt { color: var(--text-muted); font-size: var(--font-size-compact); }
.ticket-metadata dd { margin: 0 0 var(--space-2); overflow-wrap: anywhere; }
.ticket-metadata .ticket-editor { margin-top: var(--space-3); }
.ticket-detail-page .ticket-control-rail summary {
cursor: pointer;
color: var(--text-strong);
font-size: var(--font-size-body);
font-weight: 600;
}
.ticket-control-rail details[open] > summary { margin-bottom: var(--space-3); }
.ticket-control-rail p { margin-block: var(--space-2); }
.ticket-control-rail .ticket-section-heading { flex-wrap: wrap; }
.ticket-control-rail .ticket-handling details { margin-block: var(--space-2); }
.ticket-detail-page .workspace-primary-button {
border-color: var(--text-strong);
background: var(--text-strong);
color: var(--bg);
}
@container ticket-detail (max-width: 48rem) {
.ticket-detail-grid {
grid-template-columns: minmax(0, 1fr);
}
.ticket-detail-page > .ticket-detail-grid { grid-template-columns: minmax(0, 1fr); }
.ticket-column-divider { display: none; }
.ticket-mobile-sidebar-divider { display: block; }
}
@media (max-width: 64rem) {
.ticket-detail-grid {
grid-template-columns: 1fr;
}
.ticket-detail-grid, .ticket-detail-page > .ticket-detail-grid { grid-template-columns: minmax(0, 1fr); }
.ticket-column-divider { display: none; }
.ticket-mobile-sidebar-divider { display: block; }
}
}
@@ -0,0 +1,106 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { TicketAssignmentTrackingProjection, TicketDetail } from "#lib/generated/ticket-api.ts";
import { fixtureDetail } from "#lib/workspace/home/dashboard.test-fixtures.ts";
import TicketHandling from "./TicketHandling.svelte";
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
const assignmentsPath = "/workspaces/team/tickets/internal-T-1/assignment-tracking/assignments?limit=20";
const assignmentPath = "/api/w/team/tickets/internal-T-1/assignments/orchestrator";
const answerPath = "/api/w/team/tickets/internal-T-1/assignment-answer";
function assigned(status: "waiting" | "done" = "waiting"): TicketDetail {
const row: TicketAssignmentTrackingProjection = {
assignment_id: "assignment-1", role: "orchestrator", ticket_id: "internal-T-1", workspace_id: "team",
assigned_at: "2026-10-13T09:00:00Z", assigned_by: "Mika", current: true,
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, last_event_id: "handling-1",
tracking: { status, waiting_reason: "Choose the policy", progress: "Compared options", next_action: "Confirm the choice", result: "Comparison delivered" },
};
return { ...fixtureDetail("T-1"), assignment_tracking: [row],
assignments: [{ assignment_id: row.assignment_id, role: row.role, principal: row.principal, assigned_at: row.assigned_at, assigned_by: row.assigned_by }],
action_eligibility: { ...fixtureDetail("T-1").action_eligibility, can_assign_orchestrator: true },
};
}
function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise<Response> = () => { throw new Error("Unexpected request"); }) {
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input);
if (path === assignmentsPath) return Response.json({ items: [], next_assignment_id: null });
return await handle(path, init);
});
vi.stubGlobal("fetch", fetcher);
return fetcher;
}
function mount(ticket = assigned(), refresh = vi.fn(async () => {})) {
return { ...render(TicketHandling, { workspaceId: "team", ticket, orchestrator: null, refresh }), refresh };
}
function expectNoDiagnostics(fetcher: ReturnType<typeof installFetch>, view: ReturnType<typeof mount>) {
expect(view.container.querySelector(".ticket-delivery")).toBeNull();
expect(view.queryByText("Delivery and execution")).toBeNull();
expect(view.queryByRole("button", { name: /delivery diagnostics/i })).toBeNull();
expect(fetcher.mock.calls.map(([input]) => String(input)).some((path) => path.includes("assignment-diagnostics"))).toBe(false);
}
test("mount retains assignment and self-reported handling, progress and result without delivery UI or diagnostics reads", async () => {
const fetcher = installFetch();
const view = mount();
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
expect(view.getByText("Self-reported handling: waiting")).toBeTruthy();
for (const text of ["Choose the policy", "Compared options", "Confirm the choice", "Comparison delivered", "Assignment details"]) expect(view.getByText(text)).toBeTruthy();
expect(view.container.querySelector(".ticket-orchestrator-worker")).not.toBeNull();
expectNoDiagnostics(fetcher, view);
});
test("assignment save refreshes assignment history without diagnostics or delivery/execution notice", async () => {
const fetcher = installFetch((path, init) => {
expect(path).toBe(assignmentPath);
expect(init?.method).toBe("PUT");
return Response.json({ ticket_id: "internal-T-1", workspace_id: "team", assignment: {
assignment_id: "assignment-2", assigned_at: "2026-10-13T10:00:00Z", assigned_by: "Mika", role: "orchestrator",
ticket_id: "internal-T-1", workspace_id: "team", principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
} });
});
const view = mount(assigned("done"));
await fireEvent.click(view.getByRole("button", { name: "Request again (new assignment)" }));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(3);
expect(view.queryByText("Loading assignment history…")).toBeNull();
});
expect(view.refresh).toHaveBeenCalledTimes(1);
expect(view.getByRole("status").textContent).toBe("Assignment operation saved: assignment-2.");
expectNoDiagnostics(fetcher, view);
});
test("answer save keeps handling unchanged and refreshes history without diagnostics reads", async () => {
const fetcher = installFetch((path, init) => {
expect(path).toBe(answerPath);
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toMatchObject({ assignment_id: "assignment-1", expected_last_event_id: "handling-1", body: "Use read-only comparison" });
return Response.json({ assignment_id: "assignment-1", input_event_id: "input-1", event_index: 1 });
});
const view = mount();
await fireEvent.input(view.getByRole("textbox", { name: "Answer / continue this handling" }), { target: { value: "Use read-only comparison" } });
await fireEvent.click(view.getByRole("button", { name: "Send answer to this assignment" }));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(3);
expect(view.queryByText("Loading assignment history…")).toBeNull();
});
expect(view.refresh).toHaveBeenCalledTimes(1);
expect(view.getByRole("status").textContent).toContain("Handling status is unchanged.");
expect(view.getByText("Self-reported handling: waiting")).toBeTruthy();
expectNoDiagnostics(fetcher, view);
});
test("uncertain assignment reconciliation only refreshes the projection and assignment history", async () => {
const fetcher = installFetch(() => { throw new Error("Response lost"); });
const view = mount(assigned("done"));
await fireEvent.click(view.getByRole("button", { name: "Request again (new assignment)" }));
await waitFor(() => expect(view.getByRole("alert").textContent).toContain("Outcome unknown"));
await fireEvent.click(view.getByRole("button", { name: "Refresh and reconcile assignment" }));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(3);
expect(view.queryByText("Loading assignment history…")).toBeNull();
});
expect(view.refresh).toHaveBeenCalledTimes(1);
expect(view.queryByRole("alert")).toBeNull();
expectNoDiagnostics(fetcher, view);
});
@@ -1,10 +1,11 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import type { TicketDetail, TicketAssignmentTrackingProjection, TicketAssignmentTrackingHistoryPage, TicketAssignmentsPage, TicketAssignmentDiagnosticsPage, TicketAssignmentPrincipalSnapshot } from "#lib/generated/ticket-api.ts";
import BevelLine from "#lib/workspace/ui/BevelLine.svelte";
import type { TicketDetail, TicketAssignmentTrackingProjection, TicketAssignmentTrackingHistoryPage, TicketAssignmentsPage, TicketAssignmentPrincipalSnapshot } from "#lib/generated/ticket-api.ts";
import type { WorkspaceOrchestratorStatus } from "./ticket-panel.ts";
import { workspaceApiJson, workspaceApiJsonWithBody, workspaceApiPath, formatDate } from "#lib/workspace/api/http.ts";
import { workspaceApiJson, workspaceApiJsonWithBody, formatDate } from "#lib/workspace/api/http.ts";
import { parseTicketRoleAssignmentMutationResponse, TICKET_BROWSER_API_MAX_RESPONSE_BYTES } from "#lib/workspace/api/ticket-browser.ts";
import { assignmentOperation, answerOperation, parseAnswerReceipt, parseAssignmentsPage, parseAssignmentDiagnostics, operationError, parseTracking, parseTrackingHistory, trackingPath, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
import { assignmentOperation, answerOperation, parseAnswerReceipt, parseAssignmentsPage, operationError, parseTracking, parseTrackingHistory, trackingPath, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
let { workspaceId, ticket, orchestrator, refresh }: { workspaceId: string; ticket: TicketDetail; orchestrator: WorkspaceOrchestratorStatus | null; refresh: () => Promise<void> } = $props();
const current = $derived(ticket.assignment_tracking.find((row) => row.current && row.role === "orchestrator"));
let busy = $state(false);
@@ -14,10 +15,6 @@
let pendingKind = $state<"assignment" | "answer">("assignment");
let assignments = $state<TicketAssignmentsPage | null>(null);
let assignmentPageBusy = $state(false);
let diagnostic = $state<TicketAssignmentDiagnosticsPage | null>(null);
let diagnosticError = $state<string | null>(null);
let diagnosticBusy = $state(false);
let diagnosticRead = 0;
let answer = $state("");
let principalKind = $state<"workspace_agent" | "user" | "worker">("workspace_agent");
let accountId = $state("");
@@ -48,12 +45,11 @@
} else {
const result = await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketRoleAssignmentMutationResponse, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (!alive) return;
savedNotice = `Assignment operation saved: ${result.assignment?.assignment_id ?? "released"}. Delivery and execution are separate.`;
savedNotice = `Assignment operation saved: ${result.assignment?.assignment_id ?? "released"}.`;
}
pending = null;
await refresh();
await readAssignments();
if (current) await readDiagnostics(current.assignment_id);
} catch (cause) { if (alive) error = savedNotice ? `Operation saved; latest projection unavailable. ${operationError(cause)}` : operationError(cause); }
finally { busy = false; }
}
@@ -64,7 +60,7 @@
async function reconcile() {
if (busy) return;
busy = true;
try { await refresh(); if (!alive) return; pending = null; error = null; savedNotice = null; await readAssignments(); if (current) await readDiagnostics(current.assignment_id); }
try { await refresh(); if (!alive) return; pending = null; error = null; savedNotice = null; await readAssignments(); }
catch (cause) { if (alive) error = operationError(cause); }
finally { busy = false; }
}
@@ -96,36 +92,17 @@
} catch (cause) { historyError = operationError(cause); }
finally { assignmentPageBusy = false; }
}
async function readDiagnostics(id: string, more = false) {
const read = ++diagnosticRead;
diagnosticBusy = true; diagnosticError = null;
try {
const query = new URLSearchParams({ assignment_id: id, limit: "20" });
if (more && diagnostic?.assignment_id === id) {
const inputCursor = diagnostic.next_input_event_id ?? diagnostic.inputs.at(-1)?.input_event_id;
const startCursor = diagnostic.next_start_operation_id ?? diagnostic.starts.at(-1)?.operation_id;
if (inputCursor) query.set("after_input_event_id", inputCursor);
if (startCursor) query.set("after_start_operation_id", startCursor);
}
const page = await workspaceApiJson(workspaceApiPath(workspaceId, "/tickets/" + encodeURIComponent(ticket.id) + "/assignment-diagnostics?") + query, parseAssignmentDiagnostics, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (!alive || read !== diagnosticRead) return;
diagnostic = more && diagnostic?.assignment_id === id ? { ...page, inputs: [...diagnostic.inputs, ...page.inputs], starts: [...diagnostic.starts, ...page.starts] } : page;
} catch (cause) { if (alive && read === diagnosticRead) diagnosticError = operationError(cause); }
finally { if (read === diagnosticRead) diagnosticBusy = false; }
}
function submitAnswer(event: SubmitEvent) {
event.preventDefault();
if (!current || pending || !answer.trim()) return;
void run(answerOperation(workspaceId, ticket.id, current, answer, crypto.randomUUID()), "answer");
}
onMount(() => { void readAssignments(); if (current) void readDiagnostics(current.assignment_id); });
onMount(() => { void readAssignments(); });
</script>
<section class="ticket-detail-section ticket-handling">
<div class="ticket-section-heading"><h2>Orchestrator handling</h2><a href={`/w/${encodeURIComponent(workspaceId)}/orchestrator`}>All handling</a></div>
{#if current}
<p><strong>Self-reported handling: {current.tracking?.status ?? "unknown"}</strong></p>
<p>Principal: {current.principal.kind === "workspace_agent" ? current.principal.agent_key : current.principal.kind === "worker" ? `${current.principal.runtime_id}/${current.principal.worker_id}` : current.principal.account_id}</p>
<p>Assignment <code>{current.assignment_id}</code> · assigned by {current.assigned_by} · {formatDate(current.assigned_at)}</p>
{#if current.tracking}
<dl>
{#if current.tracking.waiting_reason}<dt>Waiting reason</dt><dd>{current.tracking.waiting_reason}</dd>{/if}
@@ -137,7 +114,10 @@
{#if current.updated_at}<p>Updated {formatDate(current.updated_at)}</p>{/if}
{#if current.migration_diagnostic}<p class="workspace-callout">{current.migration_diagnostic}</p>{/if}
{#if current.tracking?.status === "done"}<p>This handling was returned. Ticket {ticket.state} is unchanged.</p>{/if}
<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(current.assignment_id)}>View handling history</button>
<details class="ticket-assignment-details"><summary>Assignment details</summary>
<p>Principal: {current.principal.kind === "workspace_agent" ? current.principal.agent_key : current.principal.kind === "worker" ? `${current.principal.runtime_id}/${current.principal.worker_id}` : current.principal.account_id}</p>
<p>Assignment <code>{current.assignment_id}</code> · assigned by {current.assigned_by} · {formatDate(current.assigned_at)}</p>
</details>
{:else}<p class="workspace-empty-copy">No current Orchestrator assignment.</p>{/if}
{#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if}
{#if savedNotice}<p role="status">{savedNotice}</p>{/if}
@@ -175,6 +155,7 @@
</form>
{/if}
<details class="ticket-assignment-history"><summary>Past assignment and release history</summary>
{#if current}<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(current.assignment_id)}>View handling history</button>{/if}
{#if assignmentPageBusy}<p role="status">Loading assignment history…</p>{/if}
{#each assignments?.items ?? [] as row (row.assignment_id)}<p><button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(row.assignment_id)}>{row.current ? "Current" : "Past"} · {row.tracking?.status ?? "unknown"} · {formatDate(row.assigned_at)}</button> <code>{row.assignment_id}</code>{#if row.release} · {row.release.action}{/if}{#if row.tracking?.result} · {row.tracking.result}{/if}</p>{/each}
{#if assignments?.next_assignment_id}<button class="workspace-secondary-button" disabled={assignmentPageBusy} onclick={() => void readAssignments(true)}>More assignments</button>{/if}
@@ -188,32 +169,17 @@
{#if history}{#each history.items as entry (entry.event_id)}<article><p>{entry.tracking.status} · {formatDate(entry.at)}</p>{#if entry.tracking.waiting_reason}<p>Waiting reason: {entry.tracking.waiting_reason}</p>{/if}{#if entry.tracking.progress}<p>Progress: {entry.tracking.progress}</p>{/if}{#if entry.tracking.next_action}<p>Next action: {entry.tracking.next_action}</p>{/if}{#if entry.tracking.result}<p>Result: {entry.tracking.result}</p>{/if}</article>{/each}{#if history.next_event_id}<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(historyId, true)}>More handling history</button>{/if}{/if}
</details>
</section>
<section class="ticket-detail-section ticket-delivery">
<div class="ticket-section-heading"><h2>Delivery and execution</h2></div>
<p>Assignment persistence: {current ? "Saved: " + current.assignment_id : "No current assignment"}</p>
{#if current}<button class="workspace-secondary-button" disabled={diagnosticBusy} onclick={() => current && void readDiagnostics(current.assignment_id)}>Refresh delivery diagnostics</button>{/if}
{#if diagnosticError}<p class="workspace-callout is-error" role="alert">{diagnosticError}</p>{/if}
{#if diagnosticBusy}<p role="status">Loading delivery diagnostics…</p>{/if}
{#if diagnostic}
<p>Assignment <code>{diagnostic.assignment_id}</code> · {diagnostic.actionable ? "actionable input" : "retained history / not actionable"}</p>
{#if !diagnostic.input_origin_known}<p>Input origin unknown; not inferred from handling.</p>{/if}
{#each diagnostic.inputs as input (input.input_event_id)}
<article><p>Input <code>{input.input_event_id}</code></p><dl><dt>Notification</dt><dd>{input.notification.replaceAll("_", " ")}</dd><dt>Runtime acceptance</dt><dd>{input.notification === "runtime_accepted" ? "Accepted immutable notification" : input.acceptance_outcome_unknown ? "Outcome unknown — may already be accepted" : "No acceptance acknowledgement observed"}</dd></dl></article>
{:else}<p>No delivery input records in this page.</p>{/each}
<h3>Work start / admission evidence</h3>
{#each diagnostic.starts as start (start.operation_id)}<p><code>{start.operation_id}</code>: {start.claim_state} · {start.runtime_id ?? "runtime unknown"}/{start.worker_id ?? "worker unknown"}{#if start.actual_processing_unknown} · actual model processing not attested{/if}</p>{:else}<p>No work start records observed. This is not inferred from Worker state.</p>{/each}
{#if diagnostic.next_input_event_id || diagnostic.next_start_operation_id}<button class="workspace-secondary-button" disabled={diagnosticBusy} onclick={() => diagnostic && void readDiagnostics(diagnostic.assignment_id, true)}>More delivery / start records</button>{/if}
{:else}<p>Delivery / Runtime acceptance / work start not observed.</p>{/if}
</section>
<section class="ticket-detail-section ticket-orchestrator-worker">
<div class="ticket-section-heading"><h2>Orchestrator Worker</h2></div>
<BevelLine decorative />
<details class="ticket-detail-section ticket-orchestrator-worker">
<summary>Orchestrator Worker</summary>
{#if orchestrator?.worker}<p>{orchestrator.worker.runtime_id}/{orchestrator.worker.worker_id} · actual state: <strong>{orchestrator.worker.state}</strong> · {orchestrator.online ? "online" : "offline"}</p><a href={`/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(orchestrator.worker.worker_id)}?runtimeId=${encodeURIComponent(orchestrator.worker.runtime_id)}`}>Open Worker</a>{:else}<p>Worker observation unavailable. This does not change handling status.</p>{/if}
</section>
</details>
<style>
.assignment-principal fieldset { min-width: 0; margin: var(--space-3, 0.75rem) 0 0; padding: 0; border: 0; }
dl { display: grid; grid-template-columns: minmax(7rem, 1fr) minmax(0, 3fr); gap: 0.5rem 1rem; }
dl { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
dd + dt { margin-top: var(--space-2); }
dt { color: var(--text-muted); } dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
code { overflow-wrap: anywhere; } .handling-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-block: 1rem; }
article + article { margin-top: 1rem; }
@media (max-width: 540px) { dl { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,94 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { TicketEventDetail } from "#lib/generated/ticket-api.ts";
import TicketPost from "./TicketPost.svelte";
afterEach(cleanup);
function post(overrides: Partial<TicketEventDetail> = {}): TicketEventDetail {
return { sequence: 0, event_ref: "post-1", kind: "comment", is_initial: true,
author: "Mika", at: "2026-10-12T09:00:00Z", body: "Original request",
editable: true, body_truncated: false, body_digest: "a".repeat(64), edited_at: null, edited_by: null,
attributes: {}, references: [], ...overrides };
}
test.each([true, false])("initial=%s has the same author, timestamp and body-only edit action", async (is_initial) => {
const value = post({ is_initial });
const save = vi.fn(async () => {});
const load = vi.fn(async () => value);
const view = render(TicketPost, { post: value, busy: false, load, save });
expect(view.getByText("Mika")).toBeTruthy();
expect(view.container.querySelector("time")?.dateTime).toBe(value.at);
await fireEvent.click(view.getByRole("button", { name: "Edit post" }));
expect(view.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", value.body);
expect(view.queryByRole("textbox", { name: "Title" })).toBeNull();
await fireEvent.input(view.getByRole("textbox", { name: "Post body" }), { target: { value: "Revised request" } });
await fireEvent.click(view.getByRole("button", { name: "Save post" }));
await waitFor(() => expect(save).toHaveBeenCalledWith(value, "Revised request"));
expect(load).not.toHaveBeenCalled();
});
test("non-editable and unknown-author posts do not infer authorship or editing permission", () => {
const value = post({ editable: false, author: null, at: null });
const view = render(TicketPost, { post: value, busy: false, load: async () => value, save: async () => {} });
expect(view.getByText("Unknown author")).toBeTruthy();
expect(view.getByText("Unknown time")).toBeTruthy();
expect(view.queryByRole("button", { name: "Edit post" })).toBeNull();
});
test("edited metadata is separate from the preserved original author and time", () => {
const value = post({ edited_at: "2026-10-13T11:30:00Z", edited_by: "Rin" });
const view = render(TicketPost, { post: value, busy: false, load: async () => value, save: async () => {} });
expect(view.getByText("Mika")).toBeTruthy();
expect(view.container.querySelector(".ticket-post-edited")?.textContent).toContain("by Rin");
expect(Array.from(view.container.querySelectorAll("time")).map((time) => time.dateTime)).toEqual([value.at, value.edited_at]);
});
test.each(["close", "state_changed", "assignment_answer", "plan", "decision", "implementation_report"])("immutable %s records never offer post editing", (kind) => {
const value = post({ kind, is_initial: false });
const view = render(TicketPost, { post: value, busy: false, load: async () => value, save: async () => {} });
expect(view.queryByRole("button", { name: "Edit post" })).toBeNull();
});
test("truncated post fetches the full body and its observation before editing", async () => {
const preview = post({ body: "Short preview", body_truncated: true });
const full = post({ body: "Complete post including the unseen ending", body_digest: "b".repeat(64) });
const save = vi.fn(async () => {});
const load = vi.fn(async () => full);
const view = render(TicketPost, { post: preview, busy: false, load, save });
expect(view.queryByRole("textbox")).toBeNull();
await fireEvent.click(view.getByRole("button", { name: "Edit post" }));
await waitFor(() => expect(view.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", full.body));
await fireEvent.click(view.getByRole("button", { name: "Save post" }));
await waitFor(() => expect(save).toHaveBeenCalledWith(full, full.body));
expect(load).toHaveBeenCalledWith(preview);
});
test("a failed or still-truncated full-post read cannot open a partial editor", async () => {
const preview = post({ body: "Short preview", body_truncated: true });
const save = vi.fn(async () => {});
const view = render(TicketPost, { post: preview, busy: false, load: async () => preview, save });
await fireEvent.click(view.getByRole("button", { name: "Edit post" }));
await waitFor(() => expect(view.getByRole("alert").textContent).toContain("No partial text can be saved"));
expect(view.queryByRole("textbox")).toBeNull();
expect(save).not.toHaveBeenCalled();
});
test("conflicts retain the draft and stale digest until explicit discard and reload", async () => {
const value = post();
const current = post({ body: "Another editor's update", body_digest: "c".repeat(64) });
const save = vi.fn(async () => { throw new Error("HTTP (409)"); });
const load = vi.fn(async () => current);
const view = render(TicketPost, { post: value, busy: false, load, save });
await fireEvent.click(view.getByRole("button", { name: "Edit post" }));
await fireEvent.input(view.getByRole("textbox", { name: "Post body" }), { target: { value: "My draft" } });
await fireEvent.click(view.getByRole("button", { name: "Save post" }));
await waitFor(() => expect(view.getByRole("alert").textContent).toContain("Post update conflict"));
expect(view.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", "My draft");
expect(view.getByRole("button", { name: "Save post" })).toHaveProperty("disabled", true);
expect(save).toHaveBeenCalledWith(value, "My draft");
expect(load).not.toHaveBeenCalled();
await fireEvent.click(view.getByRole("button", { name: "Discard draft and load current post" }));
await waitFor(() => expect(view.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", current.body));
expect(view.getByRole("button", { name: "Save post" })).toHaveProperty("disabled", false);
});
@@ -0,0 +1,121 @@
<script lang="ts">
import type { TicketEventDetail } from "#lib/generated/ticket-api.ts";
import RichMarkdown from "#lib/workspace/console/RichMarkdown.svelte";
import { relationLabel } from "./ticket-panel.ts";
const { post, busy, load, save } = $props<{
post: TicketEventDetail;
busy: boolean;
load: (post: TicketEventDetail) => Promise<TicketEventDetail>;
save: (post: TicketEventDetail, body: string) => Promise<void>;
}>();
let editing = $state(false);
let loading = $state(false);
let saving = $state(false);
let observed = $state<TicketEventDetail | null>(null);
let fullPost = $state<TicketEventDetail | null>(null);
let fullPostSourceDigest = $state<string | null>(null);
let draft = $state("");
let error = $state<string | null>(null);
let reconcile = $state(false);
const displayed = $derived(fullPost && fullPostSourceDigest === post.body_digest ? fullPost : post);
const editable = $derived(post.editable && post.kind === "comment");
function prettyDate(value?: string | null): string {
if (!value) return "Unknown time";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
async function readFull(edit: boolean): Promise<void> {
if (busy || loading || saving) return;
const sourceDigest = post.body_digest;
loading = true;
error = null;
try {
const current = await load(post);
if (current.event_ref !== post.event_ref || current.body_truncated) {
throw new Error("The full post could not be loaded. No partial text can be saved.");
}
fullPost = current;
fullPostSourceDigest = sourceDigest;
if (edit) {
if (!current.editable || current.kind !== "comment") throw new Error("This event is not an editable post.");
observed = current;
draft = current.body ?? "";
editing = true;
reconcile = false;
}
} catch (cause) {
error = cause instanceof Error ? cause.message : String(cause);
} finally {
loading = false;
}
}
function beginEdit(): void {
if (post.body_truncated) { void readFull(true); return; }
observed = displayed;
draft = displayed.body ?? "";
error = null;
reconcile = false;
editing = true;
}
async function savePost(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busy || saving || !observed || observed.body_truncated || reconcile) return;
saving = true;
error = null;
try {
await save(observed, draft);
editing = false;
fullPost = null;
observed = null;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
error = /\(409\)/.test(message)
? `Post update conflict. Your draft is preserved. Load the current post before editing again. ${message}`
: `Post save was not confirmed. Your draft is preserved; read the current post before retrying. ${message}`;
reconcile = true;
} finally {
saving = false;
}
}
</script>
<article class="ticket-thread-event" data-initial={post.is_initial} aria-label={post.is_initial ? "Initial post" : post.kind === "comment" ? "Discussion post" : relationLabel(post.kind)}>
<header>
<div class="ticket-post-attribution">
<strong>{displayed.author ?? "Unknown author"}</strong>
{#if displayed.kind !== "comment"}<span>{displayed.heading ?? relationLabel(displayed.kind)}</span>{/if}
<time datetime={displayed.at ?? undefined}>{prettyDate(displayed.at)}</time>
{#if displayed.edited_at}
<span class="ticket-post-edited">Edited <time datetime={displayed.edited_at}>{prettyDate(displayed.edited_at)}</time> by {displayed.edited_by ?? "Unknown editor"}</span>
{/if}
</div>
{#if editable && !editing}
<button class="workspace-secondary-button" type="button" disabled={busy || loading} onclick={beginEdit}>{loading ? "Loading post…" : "Edit post"}</button>
{/if}
</header>
{#if editing}
<form class="ticket-editor" aria-label="Edit post" onsubmit={savePost}>
<label>Post body<textarea bind:value={draft} rows="8" disabled={saving}></textarea></label>
<div class="ticket-comment-actions">
<button class="workspace-primary-button" type="submit" disabled={busy || saving || reconcile}>{saving ? "Saving…" : "Save post"}</button>
<button class="workspace-secondary-button" type="button" disabled={saving} onclick={() => { editing = false; error = null; }}>Cancel post edit</button>
</div>
{#if reconcile}<button class="workspace-secondary-button" type="button" disabled={busy || loading} onclick={() => void readFull(true)}>Discard draft and load current post</button>{/if}
</form>
{:else}
{#if displayed.from || displayed.to}<p>{displayed.from ?? "—"} → {displayed.to ?? "—"}</p>{/if}
{#if displayed.reason && displayed.reason !== displayed.body}<RichMarkdown text={displayed.reason} />{/if}
{#if displayed.body}<RichMarkdown text={displayed.body} />{:else if displayed.kind === "comment"}<p class="workspace-empty-copy">Empty post.</p>{/if}
{#each displayed.references as reference}<p>{reference}</p>{/each}
{#if displayed.body_truncated}
<p class="workspace-empty-copy">This post is shortened.</p>
<button class="workspace-secondary-button" type="button" disabled={busy || loading} onclick={() => void readFull(false)}>{loading ? "Loading post…" : "Read full post"}</button>
{/if}
{/if}
{#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if}
</article>
@@ -92,8 +92,9 @@
</details>
</section>
<style>
.ticket-tag-chips { display: flex; flex-wrap: wrap; gap: var(--space-2, 0.5rem); margin-block: 0.75rem; }
.ticket-tag-chip { padding: 0.2rem 0.5rem; background: var(--bg-subtle); border-radius: var(--radius-soft); overflow-wrap: anywhere; }
.ticket-tag-chips { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-block: var(--space-3); }
.ticket-tag-chip { overflow-wrap: anywhere; }
fieldset { min-width: 0; border: 0; padding: 0; margin: var(--space-3) 0; }
fieldset label { display: flex; align-items: center; gap: 0.5rem; }
fieldset input { width: auto; }
code { overflow-wrap: anywhere; }
@@ -27,11 +27,12 @@
}
$effect(() => { void ticket.current_worker?.worker_resource_key; untrack(() => { void load(); }); });
</script>
<section class="ticket-detail-section ticket-worker-observation">
<div class="ticket-section-heading"><h2>Related Worker — actual state</h2><button class="workspace-secondary-button" disabled={loading} onclick={() => void load()}>Refresh Worker observation</button></div>
<details class="ticket-detail-section ticket-worker-observation">
<summary>Related Worker — actual state</summary>
<button class="workspace-secondary-button" disabled={loading} onclick={() => void load()}>Refresh Worker observation</button>
{#if loading}<p role="status">Loading Worker observation…</p>{/if}
{#if error}<p role="alert" class="workspace-callout is-error">Worker observation unavailable: {error}</p>{/if}
{#if worker}<p>{worker.runtime_id}/{worker.worker_id}: <strong>{worker.state}</strong> · availability {worker.availability}</p><a href={workerHref(workspaceId, { resource_key: worker.resource_key, display_name: worker.display_name ?? worker.label })}>Open Worker</a>
{:else}<p class="workspace-empty-copy">{ticket.current_worker ? "Retained Worker identity; live state not observed." : "No related Worker recorded."}</p>{/if}
<p class="workspace-empty-copy">Worker state does not determine Ticket case state or self-reported handling.</p>
</section>
</details>
@@ -1,10 +1,12 @@
<script lang="ts">
import { untrack } from "svelte";
import TicketPost from "#lib/workspace/tickets/TicketPost.svelte";
import TicketTags from "#lib/workspace/tickets/TicketTags.svelte";
import TicketWorkerObservation from "#lib/workspace/tickets/TicketWorkerObservation.svelte";
import TicketHandling from "#lib/workspace/tickets/TicketHandling.svelte";
import { observeOperation, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
import RichMarkdown from "#lib/workspace/console/RichMarkdown.svelte";
import BevelLine from "#lib/workspace/ui/BevelLine.svelte";
import {
workspaceApiJson,
workspaceApiJsonWithBody,
@@ -14,6 +16,7 @@
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketSummary,
parseTicketDetail,
parseTicketEvent,
parseTicketRoleAssignmentMutationResponse,
} from "#lib/workspace/api/ticket-browser.ts";
import { ticketCaseRequest } from "#lib/workspace/api/ticket-case.ts";
@@ -25,6 +28,8 @@
} from "#lib/workspace/tickets/ticket-panel.ts";
import type {
TicketCaseAction,
TicketEventDetail,
TicketPostEditRequest,
TicketClosureReason,
TicketTarget,
TicketTargetAccess,
@@ -72,11 +77,9 @@
const mergeRequests = $derived(ticket.merge_requests);
let editing = $state(false);
let editTitle = $state(loadedTicket.title);
let editBody = $state(loadedTicket.body);
let targetDrafts = $state<EditableTicketTarget[]>(
editableTargets(loadedTicket.targets),
);
let transitionReason = $state("");
let closureReason = $state("unknown");
let otherClosureReason = $state("");
let threadRole = $state("comment");
@@ -85,6 +88,8 @@
let errorMessage = $state<string | null>(null);
let caseOperation = $state<ObservedOperation | null>(null);
let caseSaved = $state(false);
let caseComment = $state<string | null>(null);
let commentError = $state<string | null>(null);
let manualRuntimeId = $state("");
let manualWorkerId = $state("");
let manualBindings = $state<{ alias: string; working_directory_id: string; connection_id: string }[]>([]);
@@ -134,14 +139,14 @@
function applyTicket(updatedTicket: TicketDetail): void {
ticket = updatedTicket;
editTitle = updatedTicket.title;
editBody = updatedTicket.body;
targetDrafts = editableTargets(updatedTicket.targets);
}
function resetTicketView(updatedTicket: TicketDetail): void {
applyTicket(updatedTicket);
editing = false;
transitionReason = "";
caseComment = null;
commentError = null;
closureReason = "unknown";
otherClosureReason = "";
threadRole = "comment";
@@ -299,7 +304,6 @@
if (
await mutate("edit", "", {
title: editTitle.trim(),
body: editBody,
}, "PATCH")
) editing = false;
}
@@ -331,8 +335,11 @@
}
async function caseDecision(action: TicketCaseAction): Promise<void> {
if (busy || caseOperation || !transitionReason.trim()) return;
const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID());
if (busy || caseOperation || threadRole !== "comment" || !threadBody.trim()) return;
if (action.action !== "reopen" && closureReason === "other" && !otherClosureReason.trim()) return;
// Closeの本文と案件状態は既存APIが同時に保存する。別のコメントPOSTで二重記録しない。
const request = ticketCaseRequest(ticket, action, threadBody, crypto.randomUUID());
caseComment = threadBody;
const path = `/workspaces/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(data.ticketId)}/case`;
await runCaseDecision(observeOperation(path, "POST", request));
}
@@ -343,41 +350,82 @@
const path = ticketPath;
busy = "case";
caseOperation = operation;
errorMessage = null;
commentError = null;
caseSaved = false;
try {
await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketSummary, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
const saved = await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketSummary, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
caseOperation = null;
caseSaved = true;
ticket = { ...ticket, state: saved.workflow_state };
if (threadBody === caseComment) threadBody = "";
caseComment = null;
const updated = await workspaceApiJson(path, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
transitionReason = "";
} catch (error) {
if (generation === routeGeneration) errorMessage = ticketMutationError(error);
if (generation === routeGeneration) commentError = caseSaved
? "Comment and case change saved, but the timeline could not be refreshed. Refresh the thread."
: ticketMutationError(error);
} finally {
if (generation === routeGeneration) busy = null;
}
}
async function refreshCommentThread(): Promise<void> {
const generation = routeGeneration;
await refreshTicket();
if (generation !== routeGeneration) return;
commentError = errorMessage;
errorMessage = null;
}
async function reconcileCase(): Promise<void> {
if (busy) return;
const generation = routeGeneration;
await refreshCommentThread();
if (generation !== routeGeneration) return;
if (!commentError) { caseOperation = null; caseComment = null; }
}
function closureReasonLabel(reason: TicketClosureReason): string {
return typeof reason === "string" ? relationLabel(reason) : reason.other;
}
async function appendThread(event: SubmitEvent) {
event.preventDefault();
if (!threadBody.trim()) return;
if (
await mutate("thread", "/events", {
role: threadRole,
body: threadBody.trim(),
})
) threadBody = "";
if (busy || caseOperation || !threadBody.trim()) return;
const generation = routeGeneration;
const comment = threadBody;
commentError = null;
caseSaved = false;
const saved = await mutate("thread", "/events", {
role: threadRole,
body: comment.trim(),
});
if (generation !== routeGeneration) return;
if (saved && threadBody === comment) threadBody = "";
if (!saved) { commentError = errorMessage; errorMessage = null; }
}
function eventTitle(kind: string): string {
return relationLabel(kind);
async function loadPost(post: TicketEventDetail): Promise<TicketEventDetail> {
return await workspaceApiJson(`${ticketPath}/posts/${encodeURIComponent(post.event_ref)}`, parseTicketEvent, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
}
async function savePost(post: TicketEventDetail, body: string): Promise<void> {
if (busy || post.body_truncated || !post.editable || post.kind !== "comment") throw new Error("Post cannot be saved now.");
const generation = routeGeneration;
const request: TicketPostEditRequest = { body, expected_body_digest: post.body_digest };
busy = "post";
try {
const updated = await workspaceApiJsonWithBody(`${ticketPath}/posts/${encodeURIComponent(post.event_ref)}`, {
method: "PATCH", body: JSON.stringify(request),
}, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
} finally {
if (generation === routeGeneration) busy = null;
}
}
function prettyDate(value?: string | null): string {
@@ -398,11 +446,7 @@
</div>
<h1>{ticket.title}</h1>
{#if ticket.state === "closed" && ticket.current_closure}<p><strong>Closure reason:</strong> {closureReasonLabel(ticket.current_closure.reason)}</p>{/if}
<p>Updated {prettyDate(ticket.updated_at)}</p>
</div>
<button class="workspace-secondary-button" type="button" onclick={() => editing = !editing}>
{editing ? "Cancel edit" : "Edit ticket"}
</button>
</header>
{#if errorMessage}
@@ -410,34 +454,104 @@
{/if}
{#if editing}
<form class="ticket-editor" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required /></label>
<label>Body<textarea bind:value={editBody} rows="12"></textarea></label>
<button class="workspace-primary-button" type="submit" disabled={busy !== null || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save changes"}
</button>
</form>
{/if}
<BevelLine decorative class="ticket-header-divider" />
<div class="ticket-detail-grid">
<main class="ticket-detail-main">
<section class="ticket-detail-section">
<div class="ticket-section-heading"><h2>Intent</h2></div>
{#if ticket.body}
<RichMarkdown text={ticket.body} />
{:else}
<p class="workspace-empty-copy">No body has been recorded.</p>
<main class="ticket-detail-main" aria-label="Ticket thread">
<section class="ticket-thread">
<div class="ticket-timeline">
{#key JSON.stringify([data.workspaceId, ticket.id])}
{#each [ticket.initial_post, ...ticket.events] as post, index (post.event_ref)}
{#if index > 0}<BevelLine decorative />{/if}
<TicketPost {post} busy={busy !== null} load={loadPost} save={savePost} />
{/each}
{/key}
</div>
<form class="ticket-control-form ticket-thread-composer" aria-label="Add a comment" onsubmit={appendThread}>
<BevelLine decorative />
<label>Comment<textarea bind:value={threadBody} rows="5" required disabled={busy !== null}></textarea></label>
{#if commentError}
<div class="workspace-callout is-error" role="alert">
<p>{commentError}</p>
{#if !caseOperation}<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={refreshCommentThread}>Refresh thread</button>{/if}
</div>
{/if}
{#if caseSaved}<p role="status">Comment and case change saved.</p>{/if}
{#if caseOperation}
<p role="status">The comment and case change may already be saved. Retry the same request or refresh before continuing.</p>
<div class="ticket-comment-actions">
<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={() => caseOperation && void runCaseDecision(caseOperation)}>Retry exact case decision</button>
<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={reconcileCase}>Refresh and reconcile case</button>
</div>
{/if}
<div class="ticket-comment-actions">
<button class="workspace-primary-button" type="submit" disabled={busy !== null || caseOperation !== null || !threadBody.trim()}>{busy === "thread" ? "Saving…" : threadRole === "comment" ? "Add comment" : "Append event"}</button>
{#if threadRole === "comment"}
<button class="workspace-secondary-button" type="button" disabled={busy !== null || caseOperation !== null || !threadBody.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision(ticket.state === "open" ? { action: "close", reason: selectedClosureReason() } : { action: "reopen" })}>
{busy === "case" ? "Saving…" : ticket.state === "open" ? "Comment and close" : "Reopen ticket"}
</button>
{/if}
</div>
{#if threadRole === "comment"}
<details class="ticket-close-options">
<summary>{ticket.state === "open" ? `Close reason: ${closureReasonLabel(selectedClosureReason())}` : "Correct closure"}</summary>
<label>Closure reason<select bind:value={closureReason} disabled={busy !== null || caseOperation !== null}>
<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</option>
</select></label>
{#if closureReason === "other"}<label>Other reason<input bind:value={otherClosureReason} disabled={busy !== null || caseOperation !== null} /></label>{/if}
{#if ticket.state === "closed" && ticket.current_closure}
<button class="workspace-secondary-button" type="button" disabled={busy !== null || caseOperation !== null || !threadBody.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
{/if}
</details>
{/if}
<details class="ticket-record-type"><summary>Record type</summary>
<label>Role<select bind:value={threadRole} disabled={busy !== null || caseOperation !== null}>
<option value="comment">Comment</option>
<option value="plan">Plan</option>
<option value="decision">Decision</option>
<option value="implementation_report">Implementation report</option>
</select></label>
</details>
</form>
</section>
</main>
<div class="ticket-column-divider"><BevelLine direction="y" length="100%" decorative /></div>
<aside class="ticket-control-rail" aria-label="Ticket details">
<div class="ticket-mobile-sidebar-divider"><BevelLine decorative /></div>
<section class="ticket-control-card ticket-metadata" aria-label="Ticket metadata">
<dl>
<dt>Created by</dt><dd>{ticket.created_by ?? "Unknown author"}</dd>
<dt>Created</dt><dd><time datetime={ticket.created_at ?? undefined}>{prettyDate(ticket.created_at)}</time></dd>
<dt>Updated</dt><dd><time datetime={ticket.updated_at ?? undefined}>{prettyDate(ticket.updated_at)}</time></dd>
</dl>
<button class="workspace-secondary-button" type="button" onclick={() => { editTitle = ticket.title; editing = !editing; }}>
{editing ? "Cancel title edit" : "Edit title"}
</button>
{#if editing}
<form class="ticket-editor" aria-label="Edit ticket title" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required disabled={busy !== null} /></label>
<button class="workspace-primary-button" type="submit" disabled={busy !== null || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save title"}
</button>
</form>
{/if}
</section>
<BevelLine decorative />
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
{#key JSON.stringify([data.workspaceId, ticket.id])}
<TicketTags workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
<TicketHandling workspaceId={data.workspaceId} {ticket} orchestrator={data.orchestrator.data} refresh={refreshOperationTicket} />
<TicketWorkerObservation workspaceId={data.workspaceId} {ticket} />
<BevelLine decorative />
<TicketTags workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
{/key}
<section class="ticket-detail-section">
<BevelLine decorative />
<section class="ticket-detail-section ticket-relations">
<div class="ticket-section-heading">
<h2>Relations</h2>
<span>{ticket.relations.outgoing.length + ticket.relations.incoming.length}</span>
@@ -488,67 +602,13 @@
</div>
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Timeline</h2><span>{ticket.event_count}</span>
</div>
<div class="ticket-timeline">
{#each ticket.events as event (event.sequence)}
<article>
<div class="ticket-timeline-marker"></div>
<div>
<header>
<strong>{event.heading ?? eventTitle(event.kind)}</strong>
<time>{prettyDate(event.at)}</time>
</header>
{#if event.author}<p class="ticket-event-author">{event.author}</p>{/if}
{#if event.from || event.to}<p>{event.from ?? "—"} → {event.to ?? "—"}</p>{/if}
{#if event.reason}<RichMarkdown text={event.reason} />{/if}
{#if event.body}<RichMarkdown text={event.body} />{/if}
{#each event.references as reference}<p>{reference}</p>{/each}
</div>
</article>
{:else}
<p class="workspace-empty-copy">No timeline events.</p>
{/each}
</div>
</section>
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card ticket-case-card">
<header><h2>Case decision</h2></header>
<p class="workspace-empty-copy">Open means the case remains under consideration; closed does not necessarily mean completed. Case decisions do not start or stop Workers, grant access, approve review, merge code, or change tags. Reopening does not resume an old Worker.</p>
{#if caseSaved}<p role="status">Case decision saved. This is not handling Done or MR approval.</p>{/if}
{#if caseOperation}
<p>The case decision may already be saved. The exact observed input and operation key are retained.</p>
<button class="workspace-secondary-button" disabled={busy !== null} onclick={() => caseOperation && void runCaseDecision(caseOperation)}>Retry exact case decision</button>
<button class="workspace-secondary-button" disabled={busy !== null} onclick={async () => { await refreshTicket(); if (!errorMessage) caseOperation = null; }}>Refresh and reconcile case</button>
{/if}
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); void caseDecision(ticket.state === "open" ? { action: "close", reason: selectedClosureReason() } : { action: "reopen" }); }}>
<label>Explanation<textarea bind:value={transitionReason} rows="3" required></textarea></label>
<label>Closure reason
<select bind:value={closureReason}>
<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</option>
</select>
</label>
{#if closureReason === "other"}<label>Other reason<input bind:value={otherClosureReason} required={ticket.state === "open"} /></label>{/if}
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || caseOperation !== null || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}>
{ticket.state === "open" ? "Close ticket" : "Reopen ticket"}
</button>
{#if ticket.state === "closed" && ticket.current_closure}
<button class="workspace-secondary-button" type="button" disabled={busy !== null || caseOperation !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
{/if}
</form>
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
</section>
<section class="ticket-control-card ticket-worker-card">
<header><h2>Role assignments</h2><span>Retained responsibility</span></header>
<BevelLine decorative />
{#key JSON.stringify([data.workspaceId, ticket.id])}
<TicketWorkerObservation workspaceId={data.workspaceId} {ticket} />
{/key}
<BevelLine decorative />
<details class="ticket-control-card ticket-worker-card">
<summary>Role assignments and manual Workers</summary>
<p class="workspace-empty-copy">Responsibility is retained after work ends and Worker removal; it does not indicate a running Worker or active work authority.</p>
{#if ticket.assignments.length > 0}
<ul class="ticket-assignment-list">
@@ -619,10 +679,11 @@
<p class="workspace-callout">{diagnostic}</p>
{/each}
{/if}
</section>
</details>
<section class="ticket-control-card">
<header><h2>Repository resources (optional)</h2><span>{targetDrafts.length}</span></header>
<BevelLine decorative />
<details class="ticket-control-card ticket-resources">
<summary>Repository resources ({ticket.targets.length})</summary>
<p class="workspace-empty-copy">Targets describe permitted resources, not connected attachments. Editing targets or the body does not expand live permissions. Changed resources require explicit reconnection; stale or unavailable connections are rejected by Backend.</p>
{#if data.repositories.error}<p class="workspace-callout is-error">Repository catalog unavailable: {data.repositories.error}</p>{/if}
<form class="ticket-control-form" onsubmit={saveTarget}>
@@ -661,27 +722,12 @@
{busy === "target" ? "Saving…" : "Save targets"}
</button>
</form>
</section>
<details class="ticket-control-card">
<summary>Record result or comment</summary>
<form class="ticket-control-form" onsubmit={appendThread}>
<label>Role<select bind:value={threadRole}>
<option value="comment">Comment</option>
<option value="plan">Plan</option>
<option value="decision">Decision</option>
<option value="implementation_report">Implementation report</option>
</select></label>
<label>Body<textarea bind:value={threadBody} rows="5" required></textarea></label>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !threadBody.trim()}>Append event</button>
</form>
</details>
{#if mergeRequests.length > 0}
<section class="ticket-control-card">
<header><h2>MR requirement evidence</h2></header>
<BevelLine decorative />
<details class="ticket-control-card">
<summary>MR requirement evidence</summary>
<p><strong>Current requirements:</strong> {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.</p>
<p><strong>Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}.</p>
{#if ticket.evidence.missing.length > 0}
@@ -693,9 +739,9 @@
{#if ticket.state === "closed"}
<p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p>
{/if}
</section>
</details>
{/if}
<BevelLine decorative />
<section class="ticket-control-card">
<header><h2>Merge Requests</h2></header>
<p class="workspace-empty-copy">Ticket closure is not MR approval or integration.</p>
@@ -729,6 +775,7 @@
</section>
{#if ticket.current_closure}
<BevelLine decorative />
<section class="ticket-control-card"><header><h2>Current closure</h2></header>
<p>{closureReasonLabel(ticket.current_closure.reason)} · {ticket.current_closure.author} · {prettyDate(ticket.current_closure.at)}</p>
<RichMarkdown text={ticket.current_closure.explanation} />
@@ -736,6 +783,7 @@
</section>
{/if}
{#if ticket.closure_history.length > 0}
<BevelLine decorative />
<details class="ticket-control-card"><summary>Case history ({ticket.closure_history.length})</summary>
{#each ticket.closure_history as judgment (judgment.operation_key)}
<article>
@@ -747,6 +795,7 @@
</details>
{/if}
{#if ticket.legacy_handoff}
<BevelLine decorative />
<details class="ticket-control-card"><summary>Legacy routing handoff</summary>
<p>Historical state: {ticket.legacy_handoff.workflow_state}</p>
{#if ticket.legacy_handoff.resolution}<RichMarkdown text={ticket.legacy_handoff.resolution} />{/if}
@@ -0,0 +1,290 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor, within } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import type { TicketDetail } from "#lib/generated/ticket-api.ts";
import { fixtureDetail, fixturePost } from "#lib/workspace/home/dashboard.test-fixtures.ts";
import PageContent from "./PageContent.svelte";
// 本番の詳細画面とrequest組立てを接続し、HTTP境界だけを代替する。
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
const detailPath = "/api/w/team/tickets/T-1";
const casePath = "/workspaces/team/tickets/T-1/case";
function pageData(ticket: TicketDetail) {
return { workspaceId: "team", ticketId: "T-1", ticket: { data: ticket, error: null },
repositories: { data: { items: [] }, error: null }, orchestrator: { data: null, error: null },
} as unknown as ComponentProps<typeof PageContent>["data"];
}
function initial(): TicketDetail { return fixtureDetail("T-1"); }
function commentEvent(body: string, kind = "comment") {
return { sequence: 1, kind, heading: kind === "close" ? "Closed" : "Comment", author: "User",
at: "2026-10-13T00:00:00Z", body, reason: kind === "close" ? body : null,
from: kind === "close" ? "open" : null, to: kind === "close" ? "closed" : null, references: [],
attributes: {}, event_ref: "event-1", is_initial: false, body_truncated: false,
body_digest: "d".repeat(64), editable: kind === "comment", edited_at: null, edited_by: null,
};
}
function closed(ticket: TicketDetail, body: string): TicketDetail {
return { ...ticket, state: "closed", closure_operation_key: "saved-close",
events: [commentEvent(body, "close")], event_count: 1 };
}
function savedSummary(ticket: TicketDetail) {
return { id: ticket.id, resource_key: ticket.resource_key, slug: "fixture", kind: "task",
labels: [], tags: ticket.tags, title: ticket.title, priority: ticket.priority,
status: "Closed", workflow_state: "closed", workflow_state_explicit: true };
}
function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise<Response>) {
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input);
if (path.endsWith("/assignment-tracking/assignments?limit=20")) return Response.json({ items: [], next_assignment_id: null });
if (path.endsWith("/tags")) return Response.json([]);
return await handle(path, init);
});
vi.stubGlobal("fetch", fetcher);
return fetcher;
}
function mount(ticket = initial()) {
const view = render(PageContent, { data: pageData(ticket) });
const form = view.getByRole("form", { name: "Add a comment" });
return { ...view, form, composer: within(form) };
}
async function typeComment(view: ReturnType<typeof mount>, body: string) {
await fireEvent.input(view.composer.getByRole("textbox", { name: "Comment" }), { target: { value: body } });
}
test("canonical initial post and discussion share markup and editing while all metadata stays in the sidebar", async () => {
installFetch(() => { throw new Error("Unexpected request"); });
const ticket = { ...initial(), body: "Compatibility body must not render", initial_post: fixturePost("Original user request"),
events: [commentEvent("Later discussion"), { ...commentEvent("Immutable closure", "close"), event_ref: "close-2", sequence: 2 }], event_count: 2 };
const view = mount(ticket);
const main = view.getByRole("main", { name: "Ticket thread" });
const rail = view.getByRole("complementary", { name: "Ticket details" });
const timeline = main.querySelector(".ticket-timeline")!;
const first = within(main).getByRole("article", { name: "Initial post" });
const later = within(main).getByRole("article", { name: "Discussion post" });
expect(timeline.firstElementChild).toBe(first);
expect(first.className).toBe(later.className);
expect(within(first).getByText("Original user request")).toBeTruthy();
expect(view.queryByText("Compatibility body must not render")).toBeNull();
expect(within(first).getByText("Mika")).toBeTruthy();
expect(first.querySelector("time")?.dateTime).toBe(ticket.initial_post.at);
expect(within(main).getAllByRole("button", { name: "Edit post" })).toHaveLength(2);
expect(Array.from(timeline.children).map((element) => element.matches(".bevel-line") ? "divider" : element.className))
.toEqual(["ticket-thread-event", "divider", "ticket-thread-event", "divider", "ticket-thread-event"]);
expect(timeline.nextElementSibling).toBe(view.form);
expect(view.form.firstElementChild?.matches(".bevel-line")).toBe(true);
for (const selector of [".ticket-metadata", ".ticket-handling", ".ticket-tags", ".ticket-relations", ".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
expect(main.querySelector(selector)).toBeNull();
expect(rail.querySelector(selector)).not.toBeNull();
}
expect(view.container.querySelector(".ticket-delivery")).toBeNull();
expect(view.queryByText("Delivery and execution")).toBeNull();
for (const selector of [".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
expect(rail.querySelector(selector)?.hasAttribute("open")).toBe(false);
}
expect(within(rail).getByText("Created by")).toBeTruthy();
expect(within(rail).getByText("Mika")).toBeTruthy();
await fireEvent.click(within(first).getByRole("button", { name: "Edit post" }));
expect(within(first).getByRole("textbox", { name: "Post body" })).toHaveProperty("value", ticket.initial_post.body);
expect(within(first).queryByRole("textbox", { name: "Title" })).toBeNull();
});
test("title editing only patches metadata and leaves the initial post untouched", async () => {
const ticket = initial();
const requests: unknown[] = [];
installFetch((path, init) => {
requests.push({ path, method: init?.method, body: JSON.parse(String(init?.body)) });
return Response.json({ ...ticket, title: "A clearer title" });
});
const view = mount(ticket);
const rail = view.getByRole("complementary", { name: "Ticket details" });
await fireEvent.click(within(rail).getByRole("button", { name: "Edit title" }));
await fireEvent.input(within(rail).getByRole("textbox", { name: "Title" }), { target: { value: "A clearer title" } });
expect(within(rail).queryByRole("textbox", { name: "Post body" })).toBeNull();
await fireEvent.click(within(rail).getByRole("button", { name: "Save title" }));
await waitFor(() => expect(view.getByRole("heading", { name: "A clearer title" })).toBeTruthy());
expect(requests).toEqual([{ path: detailPath, method: "PATCH", body: { title: "A clearer title" } }]);
expect(within(view.getByRole("article", { name: "Initial post" })).getByText(ticket.initial_post.body!)).toBeTruthy();
});
test.each([true, false])("initial=%s edit uses the post identity and observed body digest, not item edit", async (isInitial) => {
const ticket = { ...initial(), events: [commentEvent("Discussion")], event_count: 1 };
const post = isInitial ? ticket.initial_post : ticket.events[0];
const requests: unknown[] = [];
installFetch((path, init) => {
requests.push({ path, method: init?.method, body: JSON.parse(String(init?.body)) });
const updated = { ...post, body: "Revised post", edited_by: "Rin", edited_at: "2026-10-13T12:00:00Z", body_digest: "e".repeat(64) };
return Response.json({ ...ticket, initial_post: isInitial ? updated : ticket.initial_post, events: isInitial ? ticket.events : [updated] });
});
const view = mount(ticket);
const article = view.getByRole("article", { name: isInitial ? "Initial post" : "Discussion post" });
await fireEvent.click(within(article).getByRole("button", { name: "Edit post" }));
await fireEvent.input(within(article).getByRole("textbox", { name: "Post body" }), { target: { value: "Revised post" } });
await fireEvent.click(within(article).getByRole("button", { name: "Save post" }));
await waitFor(() => expect(within(article).getByText("Revised post")).toBeTruthy());
expect(requests).toEqual([{ path: `${detailPath}/posts/${post.event_ref}`, method: "PATCH", body: { body: "Revised post", expected_body_digest: post.body_digest } }]);
expect(within(article).getByText(post.author!)).toBeTruthy();
expect(article.querySelector("time")?.dateTime).toBe(post.at);
expect(article.querySelector(".ticket-post-edited")?.textContent).toContain("by Rin");
expect(view.container.querySelector(".ticket-timeline")?.textContent).not.toMatch(/Created|Item edit/);
});
test("truncated initial post uses the full-read route and never saves preview text", async () => {
const ticket = initial();
ticket.initial_post = { ...ticket.initial_post, body: "Preview", body_truncated: true };
const full = { ...ticket.initial_post, body: "The full initial post with its ending", body_truncated: false, body_digest: "f".repeat(64) };
const writes: unknown[] = [];
installFetch((path, init) => {
expect(path).toBe(`${detailPath}/posts/${ticket.initial_post.event_ref}`);
if (init?.method === "PATCH") {
writes.push(JSON.parse(String(init.body)));
return Response.json({ ...ticket, initial_post: full });
}
return Response.json(full);
});
const view = mount(ticket);
const article = within(view.getByRole("article", { name: "Initial post" }));
await fireEvent.click(article.getByRole("button", { name: "Edit post" }));
await waitFor(() => expect(article.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", full.body));
await fireEvent.click(article.getByRole("button", { name: "Save post" }));
await waitFor(() => expect(writes).toEqual([{ body: full.body, expected_body_digest: full.body_digest }]));
});
test("HTTP conflict does not append an edit event, overwrite the displayed post, or lose the draft", async () => {
const ticket = initial();
let writes = 0;
installFetch((path, init) => {
expect(path).toBe(`${detailPath}/posts/${ticket.initial_post.event_ref}`);
expect(init?.method).toBe("PATCH");
writes++;
return new Response("The body has changed", { status: 409 });
});
const view = mount(ticket);
const article = within(view.getByRole("article", { name: "Initial post" }));
await fireEvent.click(article.getByRole("button", { name: "Edit post" }));
await fireEvent.input(article.getByRole("textbox", { name: "Post body" }), { target: { value: "Keep my changes" } });
await fireEvent.click(article.getByRole("button", { name: "Save post" }));
await waitFor(() => expect(article.getByRole("alert").textContent).toContain("Post update conflict"));
expect(article.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", "Keep my changes");
expect(article.getByRole("button", { name: "Save post" })).toHaveProperty("disabled", true);
expect(view.container.querySelectorAll(".ticket-timeline article")).toHaveLength(1);
expect(writes).toBe(1);
});
test("comment composer follows the timeline, and Add comment does not close the Ticket", async () => {
const posts: { path: string; body: unknown }[] = [];
const ticket = initial();
installFetch((path, init) => {
posts.push({ path, body: JSON.parse(String(init?.body)) });
expect(path).toBe(`${detailPath}/events`);
return Response.json({ ...ticket, events: [commentEvent("Still investigating")], event_count: 1 });
});
const view = mount(ticket);
const timeline = view.container.querySelector(".ticket-timeline")!;
expect(timeline.nextElementSibling).toBe(view.form);
expect(view.container.querySelector("aside .ticket-thread-composer")).toBeNull();
expect(view.queryByText("Case decision", { exact: true })).toBeNull();
expect(view.composer.getByRole("button", { name: "Add comment" })).toHaveProperty("disabled", true);
expect(view.composer.getByRole("button", { name: "Comment and close" })).toHaveProperty("disabled", true);
await typeComment(view, "Still investigating");
await fireEvent.click(view.composer.getByRole("button", { name: "Add comment" }));
await waitFor(() => expect(within(timeline as HTMLElement).getByText("Still investigating")).toBeTruthy());
expect(posts).toEqual([{ path: `${detailPath}/events`, body: { role: "comment", body: "Still investigating" } }]);
expect(view.getByText("Case: open")).toBeTruthy();
expect(view.composer.getByRole("textbox", { name: "Comment" })).toHaveProperty("value", "");
});
test("Comment and close saves one explanation with the case change and renders it once in the timeline", async () => {
const ticket = initial();
const writes: { path: string; body: Record<string, unknown> }[] = [];
installFetch((path, init) => {
if (path === casePath) {
writes.push({ path, body: JSON.parse(String(init?.body)) });
return Response.json(savedSummary(ticket));
}
expect(path).toBe(detailPath);
return Response.json(closed(ticket, "Delivered the findings"));
});
const view = mount(ticket);
await typeComment(view, "Delivered the findings");
await fireEvent.change(view.composer.getByLabelText("Closure reason"), { target: { value: "completed" } });
await fireEvent.click(view.composer.getByRole("button", { name: "Comment and close" }));
await waitFor(() => expect(view.getByText("Case: closed")).toBeTruthy());
await waitFor(() => expect(view.container.querySelector(".ticket-timeline")?.textContent).toContain("Delivered the findings"));
expect(writes).toHaveLength(1);
expect(writes[0]).toEqual({ path: casePath, body: {
action: { action: "close", reason: "completed" }, explanation: "Delivered the findings",
expected_content_digest: ticket.content_digest, expected_state: "open", expected_closure_operation_key: null,
operation_key: expect.any(String), references: [],
} });
expect(within(view.container.querySelector(".ticket-timeline") as HTMLElement).getAllByText("Delivered the findings")).toHaveLength(1);
expect(view.composer.queryByRole("button", { name: "Comment and close" })).toBeNull();
expect(view.composer.getByRole("button", { name: "Reopen ticket" })).toBeTruthy();
});
test("lost Close response retains the exact request and preserves later edits on retry", async () => {
const ticket = initial();
const requests: string[] = [];
installFetch((path, init) => {
if (path === casePath) {
requests.push(String(init?.body));
if (requests.length === 1) throw new Error("Response lost");
return Response.json(savedSummary(ticket));
}
expect(path).toBe(detailPath);
return Response.json(closed(ticket, "Original close comment"));
});
const view = mount(ticket);
await typeComment(view, "Original close comment");
await fireEvent.click(view.composer.getByRole("button", { name: "Comment and close" }));
await waitFor(() => expect(view.composer.getByRole("alert").textContent).toContain("Outcome unknown"));
expect(view.composer.getByRole("button", { name: "Add comment" })).toHaveProperty("disabled", true);
await typeComment(view, "A later draft");
await fireEvent.click(view.composer.getByRole("button", { name: "Retry exact case decision" }));
await waitFor(() => expect(view.getByText("Case: closed")).toBeTruthy());
expect(requests).toHaveLength(2);
expect(requests[1]).toBe(requests[0]);
expect(view.composer.getByRole("textbox", { name: "Comment" })).toHaveProperty("value", "A later draft");
});
test("saved Close followed by a failed refresh reports success separately and retries only the read", async () => {
const ticket = initial();
let writes = 0, reads = 0;
installFetch((path) => {
if (path === casePath) { writes++; return Response.json(savedSummary(ticket)); }
expect(path).toBe(detailPath);
if (++reads === 1) throw new Error("Read failed");
return Response.json(closed(ticket, "Saved once"));
});
const view = mount(ticket);
await typeComment(view, "Saved once");
await fireEvent.click(view.composer.getByRole("button", { name: "Comment and close" }));
await waitFor(() => expect(view.composer.getByRole("alert").textContent).toContain("saved, but the timeline"));
expect(view.getByText("Case: closed")).toBeTruthy();
expect(view.composer.queryByRole("button", { name: "Retry exact case decision" })).toBeNull();
expect(view.composer.getByRole("textbox", { name: "Comment" })).toHaveProperty("value", "");
await fireEvent.click(view.composer.getByRole("button", { name: "Refresh thread" }));
await waitFor(() => expect(view.composer.queryByRole("alert")).toBeNull());
expect(writes).toBe(1);
expect(reads).toBe(2);
});
test("rejected Close keeps the draft and requires reconciliation rather than silently replacing its observation", async () => {
const ticket = initial();
let writes = 0;
installFetch((path) => {
if (path === casePath) { writes++; return new Response("stale", { status: 409 }); }
expect(path).toBe(detailPath);
return Response.json(ticket);
});
const view = mount(ticket);
await typeComment(view, "Keep this draft");
await fireEvent.click(view.composer.getByRole("button", { name: "Comment and close" }));
await waitFor(() => expect(view.composer.getByRole("alert").textContent).toContain("Update conflict"));
expect(view.getByText("Case: open")).toBeTruthy();
expect(view.composer.getByRole("textbox", { name: "Comment" })).toHaveProperty("value", "Keep this draft");
expect(view.composer.getByRole("button", { name: "Comment and close" })).toHaveProperty("disabled", true);
await fireEvent.click(view.composer.getByRole("button", { name: "Refresh and reconcile case" }));
await waitFor(() => expect(view.composer.getByRole("button", { name: "Comment and close" })).toHaveProperty("disabled", false));
expect(writes).toBe(1);
});
@@ -26,17 +26,15 @@ Deno.test("ticket detail synchronizes reused route data", () => {
});
Deno.test("ticket child operations reset across Workspaces even when the retained Ticket id matches", () => {
assertStringIncludes(
pageSource,
"{#key JSON.stringify([data.workspaceId, ticket.id])}",
);
const children = pageSource.slice(
pageSource.indexOf("{#key JSON.stringify([data.workspaceId, ticket.id])}"),
pageSource.indexOf("{/key}"),
);
assertStringIncludes(children, "<TicketTags");
assertStringIncludes(children, "<TicketHandling");
assertStringIncludes(children, "<TicketWorkerObservation");
const keyedChildren = [...pageSource.matchAll(
/\{#key JSON\.stringify\(\[data\.workspaceId, ticket\.id\]\)\}([\s\S]*?)\{\/key\}/g,
)].map((match) => match[1]);
for (const child of ["TicketTags", "TicketHandling", "TicketWorkerObservation"]) {
assert(
keyedChildren.some((block) => block.includes(`<${child}`)),
`${child} must reset for the Workspace and Ticket, whether in main or rail`,
);
}
});
Deno.test("ticket detail fences stale mutation responses", () => {
@@ -64,7 +62,7 @@ Deno.test("ticket detail fences stale mutation responses", () => {
});
Deno.test("ticket responsibility display does not claim Worker liveness or work authority", () => {
assertStringIncludes(pageSource, "Retained responsibility");
assertStringIncludes(pageSource, "Role assignments and manual Workers");
assertStringIncludes(pageSource, "after work ends and Worker removal");
assertStringIncludes(
pageSource,