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:
@@ -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}
|
||||
|
||||
+290
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user