diff --git a/src/app/globals.css b/src/app/globals.css index 14fc02b..afbe2d3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1259,6 +1259,38 @@ body { font-variant-numeric: tabular-nums; } +.work-item-status__observations { + display: grid; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; +} + +/* Keep this table within its answer panel while letting its intrinsic columns + accommodate short headers and wrap long member labels without truncation. */ +.work-item-status__table { + width: 100%; + min-width: 0; + table-layout: auto; +} + +.work-item-status__table th, +.work-item-status__table td { + vertical-align: top; + white-space: normal; +} + +.work-item-status__table thead th { + overflow-wrap: normal; + word-break: normal; +} + +.work-item-status__table tbody th, +.work-item-status__table tbody td { + overflow-wrap: anywhere; +} + .fact-chart { display: flex; flex-direction: column; diff --git a/src/components/DeterministicAnswerView.tsx b/src/components/DeterministicAnswerView.tsx index 8c41c3e..e03f5a5 100644 --- a/src/components/DeterministicAnswerView.tsx +++ b/src/components/DeterministicAnswerView.tsx @@ -19,6 +19,7 @@ import { PriorSubjectReceiptDisclosure } from "@/components/PriorSubjectReceiptD import { StructureConfirmationNotice } from "@/components/StructureConfirmationNotice"; import { StructureNeedsPanel } from "@/components/StructureNeedsPanel"; import { SubjectResolutionPanel } from "@/components/SubjectResolutionPanel"; +import { WorkItemStatusPanel } from "@/components/WorkItemStatusPanel"; import { choiceDisposition } from "@/lib/clarification"; import { isCohortIntent, rankingTable } from "@/lib/cohort-ranking"; import type { @@ -424,6 +425,7 @@ export function DeterministicAnswerView({ shape={result.interpretation.shape} /> + {isCohortIntent(result.interpretation.shape) && result.cohort !== undefined && rankingTable(result.cohort.members) !== null ? ( diff --git a/src/components/WorkItemStatusPanel.test.tsx b/src/components/WorkItemStatusPanel.test.tsx new file mode 100644 index 0000000..0f8ebcc --- /dev/null +++ b/src/components/WorkItemStatusPanel.test.tsx @@ -0,0 +1,233 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DeterministicAnswerView } from "@/components/DeterministicAnswerView"; +import { WorkItemStatusPanel } from "@/components/WorkItemStatusPanel"; +import type { + ClaimedFact, + CohortMember, + InvestigationResult, + ScalarValue, + SubjectRef, +} from "@/lib/contracts"; +import { mockScenarios } from "@/test/fixtures/investigations"; + +function subject(kind: SubjectRef["kind"], canonicalId: string, label = canonicalId): SubjectRef { + return { kind, canonical_id: canonicalId, label }; +} + +function member(subjectRef: SubjectRef, rank: number, evidenceRefIds?: string[]): CohortMember { + return { + subject: subjectRef, + rank, + inclusion_reasons: ["Included in the resolved cohort."], + ...(evidenceRefIds === undefined ? {} : { evidence_ref_ids: evidenceRefIds }), + }; +} + +function statusFact( + claimId: string, + subjectRef: SubjectRef, + value: ScalarValue, + field = "status", +): ClaimedFact { + return { + claim_id: claimId, + kind: "status", + subject: subjectRef, + field, + value, + }; +} + +function resultWith( + members: readonly CohortMember[], + facts: readonly ClaimedFact[], + shape: "discovered_cohort" | "single_subject" = "discovered_cohort", +): InvestigationResult { + const base = mockScenarios().find((scenario) => scenario.id === "complete")!.result; + return { + ...structuredClone(base), + interpretation: { ...base.interpretation, shape }, + cohort: { + kind: "work_item", + members: [...members], + rationale: "Work items were returned for the resolved scope.", + complete: true, + truncated: false, + }, + claimed_facts: [...facts], + evidence_ref_labels: { + ...(base.evidence_ref_labels ?? {}), + "evidence:alpha": "Status source: Alpha", + "evidence:beta": "Status source: Beta", + }, + }; +} + +describe("WorkItemStatusPanel", () => { + it("joins status claims by full subject identity and keeps cohort order", () => { + const alpha = subject("work_item", "same-id", "Alpha"); + const beta = subject("work_item", "work-beta", "Beta"); + const sameIdOtherKind = subject("project", "same-id", "Same ID project"); + const result = resultWith( + [member(alpha, 1), member(beta, 2)], + [ + statusFact("beta-status", beta, { string: "waiting" }), + statusFact("wrong-kind", sameIdOtherKind, { string: "project-state" }), + statusFact("alpha-status", alpha, { string: "open" }), + ], + ); + + render(); + + const panel = screen.getByTestId("work-item-status-panel"); + const rows = within(panel).getAllByTestId("work-item-status-row"); + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.getAttribute("data-subject-id"))).toEqual([ + "same-id", + "work-beta", + ]); + expect( + rows.map((row) => within(row).getByTestId("work-item-status-value").textContent), + ).toEqual(["open", "waiting"]); + expect(panel).not.toHaveTextContent("project-state"); + }); + + it("keeps missing, literal unknown, zero, and false as distinct values", () => { + const missing = subject("work_item", "missing", "Missing"); + const unknown = subject("work_item", "unknown", "Unknown"); + const zero = subject("work_item", "zero", "Zero"); + const falseValue = subject("work_item", "false", "False"); + const result = resultWith( + [member(missing, 1), member(unknown, 2), member(zero, 3), member(falseValue, 4)], + [ + statusFact("unknown-status", unknown, { string: "unknown" }), + statusFact("zero-status", zero, { integer: 0 }), + statusFact("false-status", falseValue, { boolean: false }), + ], + ); + + render(); + + const rows = screen.getAllByTestId("work-item-status-row"); + expect(within(rows[0]!).getByTestId("work-item-status-missing")).toHaveTextContent( + "No status evidence in this answer", + ); + expect(within(rows[1]!).getByTestId("work-item-status-value")).toHaveTextContent("unknown"); + expect(within(rows[2]!).getByTestId("work-item-status-value")).toHaveTextContent("0"); + expect(within(rows[3]!).getByTestId("work-item-status-value")).toHaveTextContent("false"); + }); + + it("shows recorded blank values while keeping missing status distinct", () => { + const blank = subject("work_item", "blank", "Blank"); + const whitespace = subject("work_item", "whitespace", "Whitespace"); + const missing = subject("work_item", "missing", "Missing"); + const unknown = subject("work_item", "unknown", "Unknown"); + const zero = subject("work_item", "zero", "Zero"); + const falseValue = subject("work_item", "false", "False"); + const result = resultWith( + [ + member(blank, 1), + member(whitespace, 2), + member(missing, 3), + member(unknown, 4), + member(zero, 5), + member(falseValue, 6), + ], + [ + statusFact("blank-status", blank, { string: "" }), + statusFact("whitespace-status", whitespace, { string: " " }), + statusFact("unknown-status", unknown, { string: "unknown" }), + statusFact("zero-status", zero, { integer: 0 }), + statusFact("false-status", falseValue, { boolean: false }), + ], + ); + + render(); + + const rows = screen.getAllByTestId("work-item-status-row"); + expect(within(rows[0]!).getByTestId("work-item-status-blank")).toHaveTextContent( + "Recorded blank value", + ); + expect(within(rows[1]!).getByTestId("work-item-status-blank")).toHaveTextContent( + "Recorded blank value", + ); + expect(within(rows[2]!).getByTestId("work-item-status-missing")).toHaveTextContent( + "No status evidence in this answer", + ); + expect(within(rows[3]!).getByTestId("work-item-status-value")).toHaveTextContent("unknown"); + expect(within(rows[4]!).getByTestId("work-item-status-value")).toHaveTextContent("0"); + expect(within(rows[5]!).getByTestId("work-item-status-value")).toHaveTextContent("false"); + }); + + it("preserves multiple status observations and the member evidence references", () => { + const alpha = subject("work_item", "alpha", "Alpha"); + const result = resultWith( + [member(alpha, 1, ["evidence:alpha", "evidence:beta"])], + [ + statusFact("first-observation", alpha, { string: "open" }), + statusFact("second-observation", alpha, { string: "waiting" }), + ], + ); + + render(); + + const row = screen.getByTestId("work-item-status-row"); + const statusCell = within(row).getByTestId("work-item-status-value"); + expect(statusCell.querySelectorAll("li")).toHaveLength(2); + expect(statusCell).toHaveTextContent("open"); + expect(statusCell).toHaveTextContent("waiting"); + const evidenceCell = within(row).getByTestId("work-item-status-evidence"); + expect(evidenceCell).toHaveTextContent("Status source: Alpha"); + expect(evidenceCell).toHaveTextContent("Status source: Beta"); + expect(evidenceCell).toHaveTextContent("evidence:alpha"); + expect(evidenceCell).toHaveTextContent("evidence:beta"); + }); + + it("renders only the unranked work-item cohort and leaves ranking absent", () => { + const unranked = subject("work_item", "unranked", "Unranked"); + const ranked = subject("work_item", "ranked", "Ranked"); + const otherKind = subject("repository", "repository", "Repository"); + const members = [ + member(unranked, 1), + { ...member(ranked, 2), ranking_computed: true }, + member(otherKind, 3), + ]; + const result = resultWith(members, [ + statusFact("unranked-status", unranked, { string: "open" }), + statusFact("ranked-status", ranked, { string: "waiting" }), + ]); + + const view = render(); + + const panel = screen.getByTestId("work-item-status-panel"); + expect(within(panel).getAllByTestId("work-item-status-row")).toHaveLength(1); + expect(within(panel).getByText("Unranked")).toBeInTheDocument(); + expect(within(panel).queryByText("Ranked")).toBeNull(); + + // A ranked member may still use the existing ranking panel. The new + // view owns only the unranked rows; it must not turn that member into + // a second status row. The all-unranked tuple keeps ranking absent. + const allUnranked = resultWith( + [member(unranked, 1)], + [statusFact("unranked-status", unranked, { string: "open" })], + ); + view.unmount(); + render(); + expect(screen.queryByTestId("cohort-ranking-panel")).toBeNull(); + }); + + it("is conditional on a cohort-shaped question", () => { + const item = subject("work_item", "item", "Item"); + const result = resultWith( + [member(item, 1)], + [statusFact("item-status", item, { string: "open" })], + "single_subject", + ); + + render(); + + expect(screen.queryByTestId("work-item-status-panel")).toBeNull(); + }); +}); diff --git a/src/components/WorkItemStatusPanel.tsx b/src/components/WorkItemStatusPanel.tsx new file mode 100644 index 0000000..733b034 --- /dev/null +++ b/src/components/WorkItemStatusPanel.tsx @@ -0,0 +1,151 @@ +import { useId } from "react"; + +import { EvidenceReferences } from "@/components/EvidenceReferences"; +import { SafeAnswerText } from "@/components/SafeAnswerText"; +import type { ClaimedFact, CohortMember, InvestigationResult, SubjectRef } from "@/lib/contracts"; +import { cellText } from "@/lib/fact-rows"; +import { isCohortIntent } from "@/lib/cohort-ranking"; +import { nonBlank } from "@/lib/presentation"; + +export type WorkItemStatusPanelProps = { + readonly result: InvestigationResult; +}; + +/** + * The contract does not promise canonical IDs are unique across kinds. Keep + * both parts of a subject identity when joining cohort members to facts. + */ +function subjectKey(subject: SubjectRef): string { + return JSON.stringify([subject.kind, subject.canonical_id]); +} + +function unrankedWorkItemMembers(cohort: InvestigationResult["cohort"]): CohortMember[] { + if (cohort === undefined || cohort.kind !== "work_item") return []; + return cohort.members.filter( + (member) => member.subject.kind === "work_item" && member.ranking_computed !== true, + ); +} + +function statusFactsBySubject(facts: readonly ClaimedFact[]): ReadonlyMap { + const bySubject = new Map(); + for (const fact of facts) { + // `kind: "status"` is the producer's existing status vocabulary. The + // field remains producer-owned, so the consumer does not invent or + // translate a second field vocabulary here. + if (fact.kind !== "status" || fact.subject.kind !== "work_item") continue; + const key = subjectKey(fact.subject); + const existing = bySubject.get(key); + if (existing === undefined) { + bySubject.set(key, [fact]); + } else { + existing.push(fact); + } + } + return bySubject; +} + +function StatusCell({ facts }: { readonly facts: readonly ClaimedFact[] }) { + if (facts.length === 0) { + return ( + + No status evidence in this answer + + ); + } + + return ( +
    + {facts.map((fact, index) => { + const displayValue = nonBlank(cellText(fact.value)); + return ( +
  • + {displayValue === undefined ? ( + + Recorded blank value + + ) : ( + + )} +
  • + ); + })} +
+ ); +} + +/** + * Displays the existing scalar status claims for an unranked work-item + * cohort. This is a view over the result document: it does not rank members, + * create a table-shaped fact, select a latest observation, or map provider + * states to a product judgment. + */ +export function WorkItemStatusPanel({ result }: WorkItemStatusPanelProps) { + const idPrefix = useId(); + if (!isCohortIntent(result.interpretation.shape)) return null; + + const members = unrankedWorkItemMembers(result.cohort); + if (members.length === 0) return null; + + const factsBySubject = statusFactsBySubject(result.claimed_facts); + const titleId = `${idPrefix}-work-item-status-title`; + + return ( +
+

+ Work item status +

+
+ + + + + + + + + + + {members.map((member, index) => { + const key = subjectKey(member.subject); + const statusFacts = factsBySubject.get(key) ?? []; + const evidenceRefIds = member.evidence_ref_ids; + return ( + + + + + + ); + })} + +
Work item status
Work itemStatusEvidence
+ {member.subject.label} + + + + {evidenceRefIds === undefined || + evidenceRefIds.length === 0 ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/tests/fixtures/work-item-status-result.json b/tests/fixtures/work-item-status-result.json new file mode 100644 index 0000000..364eb72 --- /dev/null +++ b/tests/fixtures/work-item-status-result.json @@ -0,0 +1,523 @@ +{ + "schema_version": "context_fabric_investigation_result.v1", + "result_id": "result_tuple_producer_001", + "request_id": "req_00000000000000000000000000000001", + "generated_at": "2026-09-14T12:00:00Z", + "status": "complete", + "question": "What is the state and count of Project Alpha work items?", + "interpretation": { + "shape": "discovered_cohort", + "requested_judgment": "status", + "subject_terms": ["Project Alpha"], + "time_context": { "axis": "current" }, + "fact_requirements": [{ "kind": "health" }], + "clarification_needed": false + }, + "subject_resolution": { + "candidates": [ + { + "receipt_id": "receipt_tuple_project", + "subject": { + "kind": "project", + "canonical_id": "project.v2:linear:P1", + "label": "Project Alpha" + }, + "state": "committed", + "matched_terms": ["Project Alpha"], + "match_reasons": ["Exact canonical subject label match."], + "confidence": 1, + "match_mechanisms": ["exact"] + } + ], + "committed": [ + { "kind": "project", "canonical_id": "project.v2:linear:P1", "label": "Project Alpha" } + ], + "commit_decision_digests": [ + { + "subject": { + "kind": "project", + "canonical_id": "project.v2:linear:P1", + "label": "Project Alpha" + } + } + ] + }, + "answer_plan": { + "family": "scoped_cohort_status", + "family_source": "model", + "family_version": "question-family.v3", + "member_kind": "work_item", + "require_drivers": false, + "require_ranking": false, + "render_kinds": ["table"], + "fact_kinds": ["status", "work"], + "axes": ["subject_anchor", "expected_kind", "window"], + "budget": { + "max_items": 50, + "max_serialized_bytes": 262144, + "max_members": 12, + "synthesis_headroom": 16, + "narrowing_basis": "canonical_id_lexical" + }, + "requirements": [ + { + "requirement": "count/member/work_item", + "obligation": "count", + "role": "member", + "subject": "work_item", + "kind": "computed", + "scope": "each_member", + "quantifier": "none", + "unavailable": "computed_population_absent" + }, + { + "requirement": "health/member/work_item", + "obligation": "health", + "role": "member", + "subject": "work_item", + "kind": "read", + "scope": "each_member", + "quantifier": "none", + "unavailable": "no_declaring_producer" + }, + { + "requirement": "state/member/work_item", + "obligation": "state", + "role": "member", + "subject": "work_item", + "kind": "read", + "fact_kinds": ["status"], + "scope": "each_member", + "quantifier": "corroborated" + } + ] + }, + "cohort": { + "kind": "work_item", + "members": [ + { + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-00", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-00" + }, + "rank": 1, + "inclusion_reasons": [ + "Work items are members of the resolved project within the authorized scope." + ], + "evidence_ref_ids": ["acr:v1:work-item:repo-1:work-00"] + }, + { + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-01", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-01" + }, + "rank": 2, + "inclusion_reasons": [ + "Work items are members of the resolved project within the authorized scope." + ], + "evidence_ref_ids": ["acr:v1:work-item:repo-1:work-01"] + }, + { + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-02", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-02" + }, + "rank": 3, + "inclusion_reasons": [ + "Work items are members of the resolved project within the authorized scope." + ], + "evidence_ref_ids": ["acr:v1:work-item:repo-1:work-02"] + }, + { + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-03", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-03" + }, + "rank": 4, + "inclusion_reasons": [ + "Work items are members of the resolved project within the authorized scope." + ], + "evidence_ref_ids": ["acr:v1:work-item:repo-1:work-03"] + }, + { + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-04", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-04" + }, + "rank": 5, + "inclusion_reasons": [ + "Work items are members of the resolved project within the authorized scope." + ], + "evidence_ref_ids": ["acr:v1:work-item:repo-1:work-04"] + }, + { + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-05", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-05" + }, + "rank": 6, + "inclusion_reasons": [ + "Work items are members of the resolved project within the authorized scope." + ], + "evidence_ref_ids": ["acr:v1:work-item:repo-1:work-05"] + }, + { + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-06", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-06" + }, + "rank": 7, + "inclusion_reasons": [ + "Work items are members of the resolved project within the authorized scope." + ], + "evidence_ref_ids": ["acr:v1:work-item:repo-1:work-06"] + }, + { + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-07", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-07" + }, + "rank": 8, + "inclusion_reasons": [ + "Work items are members of the resolved project within the authorized scope." + ], + "evidence_ref_ids": ["acr:v1:work-item:repo-1:work-07"] + }, + { + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-08", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-08" + }, + "rank": 9, + "inclusion_reasons": [ + "Work items are members of the resolved project within the authorized scope." + ], + "evidence_ref_ids": ["acr:v1:work-item:repo-1:work-08"] + }, + { + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-09", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-09" + }, + "rank": 10, + "inclusion_reasons": [ + "Work items are members of the resolved project within the authorized scope." + ], + "evidence_ref_ids": ["acr:v1:work-item:repo-1:work-09"] + }, + { + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-10", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-10" + }, + "rank": 11, + "inclusion_reasons": [ + "Work items are members of the resolved project within the authorized scope." + ], + "evidence_ref_ids": ["acr:v1:work-item:repo-1:work-10"] + }, + { + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-11", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-11" + }, + "rank": 12, + "inclusion_reasons": [ + "Work items are members of the resolved project within the authorized scope." + ], + "evidence_ref_ids": ["acr:v1:work-item:repo-1:work-11"] + } + ], + "rationale": "Work items are members of the resolved project within the authorized scope.", + "complete": true, + "truncated": false + }, + "direct_judgment": "This investigation is complete.", + "current_state": "Current observed values: status.status=open for Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-00; status.status=open for Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-01; status.status=open for Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-10; status.status=open for Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-11; status.status=open for Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-02; status.status=open for Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-03; status.status=open for Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-04; status. […truncated]", + "strongest_pressures": [], + "drivers": [], + "remaining_work": [], + "readiness_gaps": [], + "paths": [], + "conflicts": [], + "limitations": [], + "evidence_ref_ids": [ + "acr:v1:work-item:repo-1:work-00", + "acr:v1:work-item:repo-1:work-01", + "acr:v1:work-item:repo-1:work-02", + "acr:v1:work-item:repo-1:work-03", + "acr:v1:work-item:repo-1:work-04", + "acr:v1:work-item:repo-1:work-05", + "acr:v1:work-item:repo-1:work-06", + "acr:v1:work-item:repo-1:work-07", + "acr:v1:work-item:repo-1:work-08", + "acr:v1:work-item:repo-1:work-09", + "acr:v1:work-item:repo-1:work-10", + "acr:v1:work-item:repo-1:work-11" + ], + "claimed_facts": [ + { + "claim_id": "claim_tuple_0", + "kind": "status", + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-00", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-00" + }, + "field": "status", + "value": { "string": "open" } + }, + { + "claim_id": "claim_tuple_1", + "kind": "status", + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-01", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-01" + }, + "field": "status", + "value": { "string": "open" } + }, + { + "claim_id": "claim_tuple_2", + "kind": "status", + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-02", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-02" + }, + "field": "status", + "value": { "string": "open" } + }, + { + "claim_id": "claim_tuple_3", + "kind": "status", + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-03", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-03" + }, + "field": "status", + "value": { "string": "open" } + }, + { + "claim_id": "claim_tuple_4", + "kind": "status", + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-04", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-04" + }, + "field": "status", + "value": { "string": "open" } + }, + { + "claim_id": "claim_tuple_5", + "kind": "status", + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-05", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-05" + }, + "field": "status", + "value": { "string": "open" } + }, + { + "claim_id": "claim_tuple_6", + "kind": "status", + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-06", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-06" + }, + "field": "status", + "value": { "string": "open" } + }, + { + "claim_id": "claim_tuple_7", + "kind": "status", + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-07", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-07" + }, + "field": "status", + "value": { "string": "open" } + }, + { + "claim_id": "claim_tuple_8", + "kind": "status", + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-08", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-08" + }, + "field": "status", + "value": { "string": "open" } + }, + { + "claim_id": "claim_tuple_9", + "kind": "status", + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-09", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-09" + }, + "field": "status", + "value": { "string": "waiting" } + }, + { + "claim_id": "claim_tuple_10", + "kind": "status", + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-10", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-10" + }, + "field": "status", + "value": { "string": "open" } + }, + { + "claim_id": "claim_tuple_11", + "kind": "status", + "subject": { + "kind": "work_item", + "canonical_id": "work_item.v2:repo-1:work-11", + "label": "Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title Long descriptive title work-11" + }, + "field": "status", + "value": { "string": "open" } + }, + { + "claim_id": "server:cardinality:work_item", + "kind": "cardinality", + "subject": { "kind": "organization", "canonical_id": "org_1", "label": "org_1" }, + "field": "work_item_count", + "value": { "integer": 12 } + } + ], + "coverage": { + "sources": [ + { + "source": "canonical_fact:status", + "state": "available", + "label": "Canonical facts — status", + "state_label": "available" + }, + { + "source": "canonical_fact:work", + "state": "available", + "label": "Canonical facts — work", + "state_label": "available" + } + ], + "partial": false + }, + "evidence_ref_labels": { + "acr:v1:work-item:repo-1:work-00": "Work item: repo-1:work-00", + "acr:v1:work-item:repo-1:work-01": "Work item: repo-1:work-01", + "acr:v1:work-item:repo-1:work-02": "Work item: repo-1:work-02", + "acr:v1:work-item:repo-1:work-03": "Work item: repo-1:work-03", + "acr:v1:work-item:repo-1:work-04": "Work item: repo-1:work-04", + "acr:v1:work-item:repo-1:work-05": "Work item: repo-1:work-05", + "acr:v1:work-item:repo-1:work-06": "Work item: repo-1:work-06", + "acr:v1:work-item:repo-1:work-07": "Work item: repo-1:work-07", + "acr:v1:work-item:repo-1:work-08": "Work item: repo-1:work-08", + "acr:v1:work-item:repo-1:work-09": "Work item: repo-1:work-09", + "acr:v1:work-item:repo-1:work-10": "Work item: repo-1:work-10", + "acr:v1:work-item:repo-1:work-11": "Work item: repo-1:work-11" + }, + "versions": { + "service_version": "tuple-test", + "contract_version": "context_fabric_investigation_result.v1", + "backend": "graph", + "projection_version": "projection-v1", + "query_version": "query-v1", + "interpretation_version": "schema-v1", + "synthesis_version": "prompt-v1+model-v1", + "canonical_service_version": "context-fabric-facts.v2", + "model_identity": "test-provider/test-model" + }, + "deterministic_answer": "This investigation is complete. Counted 12 work items.", + "warnings": [], + "reused": false, + "effective_evidence_window": { + "start": "2026-06-16T12:00:00Z", + "end": "2026-09-14T12:00:00Z", + "relative_id": "trailing_90d", + "provenance": "question_stated" + }, + "completeness": { + "terminal_status": "complete", + "claimed_facts_count": 13, + "rows_count": 0, + "state": "degraded", + "outcomes": [ + { + "stage": "planning", + "requirement": "count/member/work_item", + "obligation": "count", + "outcome": "unavailable", + "impact": "dimension", + "cause_coverage": "fact_pruned", + "cause_observed": true, + "served": 0, + "declared": 0 + }, + { + "stage": "planning", + "requirement": "health/member/work_item", + "obligation": "health", + "outcome": "unavailable", + "impact": "dimension", + "cause_coverage": "fact_unconfigured", + "cause_observed": true, + "served": 0, + "declared": 0 + }, + { + "stage": "planning", + "requirement": "state/member/work_item", + "obligation": "state", + "outcome": "satisfied", + "impact": "none", + "cause_observed": false, + "served": 0, + "declared": 0 + }, + { + "stage": "assembled_result", + "requirement": "count/member/work_item", + "obligation": "count", + "outcome": "satisfied", + "impact": "none", + "cause_observed": false, + "served": 12, + "declared": 12 + }, + { + "stage": "assembled_result", + "requirement": "state/member/work_item", + "obligation": "state", + "outcome": "narrowed", + "impact": "depth", + "cause_coverage": "fact_narrowed", + "cause_observed": false, + "served": 1, + "declared": 2 + } + ] + } +} diff --git a/tests/work-item-status.spec.ts b/tests/work-item-status.spec.ts new file mode 100644 index 0000000..89363d7 --- /dev/null +++ b/tests/work-item-status.spec.ts @@ -0,0 +1,110 @@ +import { expect, test } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; + +import { readFileSync } from "node:fs"; + +import type { InvestigationResult } from "../src/lib/contracts"; + +const PRODUCER_FIXTURE = "tests/fixtures/work-item-status-result.json"; +const WORK_ITEM_ZERO = "work_item.v2:repo-1:work-00"; +const WORK_ITEM_MISSING = "work_item.v2:repo-1:work-01"; + +function loadProducerResult(): InvestigationResult { + return JSON.parse(readFileSync(PRODUCER_FIXTURE, "utf8")) as InvestigationResult; +} + +function resultWithStatus(result: InvestigationResult, canonicalId: string, value: string) { + const controlled = structuredClone(result); + const fact = controlled.claimed_facts.find( + (candidate) => + candidate.kind === "status" && + candidate.subject.kind === "work_item" && + candidate.subject.canonical_id === canonicalId, + ); + if (fact === undefined) throw new Error(`missing status fact for ${canonicalId}`); + fact.value = { string: value }; + return controlled; +} + +function resultWithoutStatus(result: InvestigationResult, canonicalId: string) { + const controlled = structuredClone(result); + controlled.claimed_facts = controlled.claimed_facts.filter( + (candidate) => + candidate.kind !== "status" || + candidate.subject.kind !== "work_item" || + candidate.subject.canonical_id !== canonicalId, + ); + return controlled; +} + +async function openResult(page: Page, result: InvestigationResult, width: number) { + await page.setViewportSize({ width, height: 844 }); + await page.route("**/api/investigations", (route) => + route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ result }), + }), + ); + await page.goto("/"); + await page.getByLabel("Ask a question").fill("What is the status of work items?"); + await page.getByRole("button", { name: "Send" }).click(); + const panel = page.getByTestId("work-item-status-panel"); + await expect(panel).toBeVisible(); + return panel; +} + +function rowFor(panel: Locator, id: string) { + return panel.locator(`[data-testid="work-item-status-row"][data-subject-id="${id}"]`); +} + +test.describe("Work item status panel", () => { + test("renders the retained producer values at desktop and narrow widths", async ({ page }) => { + const result = loadProducerResult(); + for (const width of [1440, 390]) { + const panel = await openResult(page, result, width); + await expect(panel.getByRole("columnheader", { name: "Work item" })).toBeVisible(); + await expect(panel.getByRole("columnheader", { name: "Status" })).toBeVisible(); + await expect(panel.getByRole("columnheader", { name: "Evidence" })).toBeVisible(); + const rows = panel.getByTestId("work-item-status-row"); + await expect(rows).toHaveCount(12); + await expect(rows.nth(0).getByTestId("work-item-status-value")).toHaveText("open"); + await expect(rows.nth(9).getByTestId("work-item-status-value")).toHaveText("waiting"); + await expect(panel).not.toContainText("work_item"); + await expect(page.getByTestId("cohort-ranking-panel")).toHaveCount(0); + await expect( + page.evaluate( + () => + document.documentElement.scrollWidth <= + document.documentElement.clientWidth, + ), + ).resolves.toBe(true); + } + }); + + test("shows recorded blank status separately from missing status", async ({ page }) => { + const producer = loadProducerResult(); + const blank = resultWithStatus(producer, WORK_ITEM_ZERO, ""); + const controlled = resultWithoutStatus(blank, WORK_ITEM_MISSING); + const panel = await openResult(page, controlled, 390); + const rows = panel.getByTestId("work-item-status-row"); + await expect(rows).toHaveCount(12); + + const blankRow = rowFor(panel, WORK_ITEM_ZERO); + await expect(blankRow.getByTestId("work-item-status-blank")).toHaveText( + "Recorded blank value", + ); + await expect(blankRow.getByTestId("work-item-status-value")).toContainText( + "Recorded blank value", + ); + + const missingRow = rowFor(panel, WORK_ITEM_MISSING); + await expect(missingRow.getByTestId("work-item-status-missing")).toHaveText( + "No status evidence in this answer", + ); + await expect(missingRow.getByTestId("work-item-status-blank")).toHaveCount(0); + await expect(rows.nth(9).getByTestId("work-item-status-value")).toHaveText("waiting"); + await expect(panel).not.toContainText("work_item"); + await expect(page.getByTestId("cohort-ranking-panel")).toHaveCount(0); + }); +});