diff --git a/apps/web/src/conversation-plan/markers.test.tsx b/apps/web/src/conversation-plan/markers.test.tsx index 2744e34d..1e82787c 100644 --- a/apps/web/src/conversation-plan/markers.test.tsx +++ b/apps/web/src/conversation-plan/markers.test.tsx @@ -179,13 +179,3 @@ test("unlinked analysis remains accessible without a status label", () => { expect(html).not.toContain("Unlinked"); expect(html).not.toContain('class="block size-1.5 rounded-full'); }); - -test("message markers render at most three accepted card links", () => { - let html = markup(state(4, "applied")); - - expect(html.match(/data-card-link=/g)).toHaveLength(3); - expect(html).toContain('data-card-link="link-1"'); - expect(html).toContain('data-card-link="link-3"'); - expect(html).not.toContain('data-card-link="link-4"'); - expect(html).toContain('aria-label="Analysis for message: applied"'); -}); diff --git a/apps/web/src/conversation-plan/markers.tsx b/apps/web/src/conversation-plan/markers.tsx index 3666b201..0059d4fd 100644 --- a/apps/web/src/conversation-plan/markers.tsx +++ b/apps/web/src/conversation-plan/markers.tsx @@ -59,12 +59,6 @@ export function MessageMarkers( ?? (links.length > 0 ? "applied" : jobsOnly ? "Planner jobs" : "unlinked"); let hasDiagnostics = !!analysis || links.length > 0 || jobs.length > 0; let visible = pinned && hasDiagnostics; - let reviewableCount = analysis && ["applied", "unlinked"].includes(analysis.status) - ? analysis.outcomes?.filter(outcome => - ["review", "ignored"].includes(outcome.status) - && !links.some(link => link.source.start === outcome.start && link.source.end === outcome.end) - ).length ?? 0 - : 0; let requestPanelFocus = () => setFocusPanelRequest(request => request + 1); let closeAnalysis = (returnFocus = false) => { setFocusPanelRequest(0); @@ -222,35 +216,7 @@ export function MessageMarkers( if (state && links.length === 0 && !analysis && jobs.length === 0) return null; return ( -
- {links.slice(0, 3).map(link => ( - - ))} - {reviewableCount > 0 && ( - - )} +
{hasDiagnostics && (
+ {links.length > 0 && ( +
+ {links.slice(0, 3).map(link => ( + + ))} +
+ )} (await wireState(page))?.analysis.find(item => item.messageId === unlinkedMessage)?.status diff --git a/e2e/source/analysis-host-linked.native.ts b/e2e/source/analysis-host-linked.native.ts new file mode 100644 index 00000000..704bd64c --- /dev/null +++ b/e2e/source/analysis-host-linked.native.ts @@ -0,0 +1,91 @@ +import { expect, test } from "@playwright/test"; +import { analysis, load, message, prepareAnalysisHost } from "./analysis-host-native"; + +test.beforeAll(prepareAnalysisHost); + +test( + "linked sources remain keyboard navigable from diagnostics", + async ({ page }) => { + await page.setViewportSize({ width: 893, height: 850 }); + let errors = await load(page); + await page.evaluate(() => window.analysisHostFixture.linked(true)); + let anchor = message(page, "host-review"); + await anchor.scrollIntoViewIfNeeded(); + let diagnosticsButton = anchor.getByRole("button", { name: /^Analysis for message:/ }); + await diagnosticsButton.press("Enter"); + let panel = analysis(page, "host-review"); + await expect(panel).toBeVisible(); + await expect(panel.getByRole("group", { name: "Linked decisions", exact: true })) + .toBeVisible(); + let cardLinks = panel.locator("[data-card-link]"); + await expect(cardLinks).toHaveCount(3); + await expect(cardLinks.nth(0)).toHaveText("Question"); + await expect(cardLinks.nth(1)).toHaveText("Proposal"); + await expect(cardLinks.nth(2)).toHaveText("Reason"); + await expect(panel.getByRole("button", { name: /^Constraint:/ })).toHaveCount(0); + await expect(panel.getByRole("button", { name: "Add another excerpt", exact: true })) + .toBeVisible(); + await page.keyboard.press("Escape"); + await diagnosticsButton.press("Enter"); + + let proposal = panel.getByRole("button", { name: /^Proposal: show card for/ }); + await proposal.focus(); + await expect(proposal).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(panel).toHaveCount(0); + expect(await page.evaluate(() => window.analysisHostFixture.cards)).toEqual(["host-thread"]); + + expect(errors).toEqual([]); + }, +); + +test( + "read-only diagnostics allow navigation but prevent correction and retries", + async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + let errors = await load(page, "readonly"); + await page.evaluate(() => window.analysisHostFixture.linked(true)); + let anchor = message(page, "host-review"); + await anchor.scrollIntoViewIfNeeded(); + await anchor.getByRole("button", { name: /^Analysis for message:/ }).press("Enter"); + let panel = analysis(page, "host-review"); + await expect(panel).toBeVisible(); + await expect(panel.getByRole("group", { name: "Linked decisions", exact: true })) + .toBeVisible(); + await expect(panel.getByRole("button", { name: "Add another excerpt", exact: true })) + .toBeDisabled(); + await expect(panel.getByRole("button", { name: "Add excerpt", exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + + let retry = message(page, "host-retry"); + await retry.getByRole("button", { name: /^Analysis for message:/ }).press("Enter"); + await expect( + analysis(page, "host-retry").getByRole("button", { + name: "Retry analysis", + exact: true, + }), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + + let jobs = message(page, "host-jobs"); + await jobs.getByRole("button", { name: /^Analysis for message:/ }).press("Enter"); + await expect( + analysis(page, "host-jobs").getByRole("button", { + name: "Retry refine job", + exact: true, + }), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + + await anchor.getByRole("button", { name: /^Analysis for message:/ }).press("Enter"); + let linkedPanel = analysis(page, "host-review"); + let questionLink = linkedPanel.getByRole("button", { name: /^Question: show card for/ }); + await questionLink.focus(); + await expect(questionLink).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(linkedPanel).toHaveCount(0); + expect(await page.evaluate(() => window.analysisHostFixture.cards)).toEqual(["host-thread"]); + + expect(errors).toEqual([]); + }, +); diff --git a/e2e/source/analysis-host-native.ts b/e2e/source/analysis-host-native.ts index 850e7eef..48428a94 100644 --- a/e2e/source/analysis-host-native.ts +++ b/e2e/source/analysis-host-native.ts @@ -82,9 +82,11 @@ export async function inspect(page: Page, id: string) { export async function openCorrection(page: Page) { let anchor = message(page, "host-review"); await anchor.scrollIntoViewIfNeeded(); - await anchor.getByRole("button", { name: "Review 1 excerpt", exact: true }).click(); + await anchor.getByRole("button", { name: /^Analysis for message:/ }).press("Enter"); let panel = analysis(page, "host-review"); - await expect(panel.getByRole("button", { name: "Add to card", exact: true })).toBeFocused(); + let correction = panel.getByRole("button", { name: "Add to card", exact: true }); + await correction.focus(); + await expect(correction).toBeFocused(); await panel.getByRole("button", { name: "Add to card", exact: true }).click(); await expect(panel.getByRole("combobox", { name: "Contribution type", exact: true })) .toBeFocused(); diff --git a/e2e/source/analysis-host.fixture.ts b/e2e/source/analysis-host.fixture.ts index edc59738..65a08425 100644 --- a/e2e/source/analysis-host.fixture.ts +++ b/e2e/source/analysis-host.fixture.ts @@ -14,6 +14,7 @@ declare global { version(version: number): void; editable(enabled: boolean): void; diagnostics(enabled: boolean): void; + linked(enabled: boolean): void; corrections: ExcerptCorrectionAction[]; analyses: Array<{ messageId: string; actionId: string }>; jobs: string[]; @@ -34,6 +35,38 @@ let analysisHostThread: ConversationPlan.Thread = { contributions: [{ id: "host-option", kind: "option", text: "S3", authoring: "quoted", sources: [], actor: { kind: "classifier" } }], stances: [], stanceHistory: [], decisionHistory: [], candidates: [], }; +let analysisHostLinkedThread: ConversationPlan.Thread = { + ...analysisHostThread, + questionSources: [{ + messageId: "host-review", author: { kind: "member", handle: "ana" }, + quote: "Prefix", start: 0, end: 6, role: "question", + }], + contributions: [ + { + ...analysisHostThread.contributions[0]!, + sources: [{ + messageId: "host-review", author: { kind: "member", handle: "ana" }, + quote: "S3", start: 8, end: 10, role: "option", + }], + }, + { + id: "host-reason", kind: "reason", text: "needs", authoring: "quoted", + sources: [{ + messageId: "host-review", author: { kind: "member", handle: "ana" }, + quote: "needs", start: 11, end: 16, role: "reason", + }], + actor: { kind: "classifier" }, + }, + { + id: "host-constraint", kind: "constraint", text: "encryption", authoring: "quoted", + sources: [{ + messageId: "host-review", author: { kind: "member", handle: "ana" }, + quote: "encryption", start: 17, end: 27, role: "constraint", + }], + actor: { kind: "classifier" }, + }, + ], +}; let analysisHostRecord: ConversationPlan.AnalysisRecord = { messageId: "host-review", questionSetVersion: "fixture-v1", modelVersion: "fixture-m1", status: "unlinked", passes: [], eventIds: [], outcomes: [{ @@ -74,15 +107,17 @@ function AnalysisHost({ mode }: { mode: "editable" | "readonly" }) { let [version, setVersion] = useState(7); let [diagnostics, setDiagnostics] = useState(true); window.analysisHostFixture.diagnostics = setDiagnostics; + let [linked, setLinked] = useState(false); window.analysisHostFixture.editable = setEditable; window.analysisHostFixture.version = setVersion; + window.analysisHostFixture.linked = setLinked; window.analysisHostFixture.append = () => setEntries(current => [...current, { id: \`appended-\${current.length}\`, author: { kind: "member", handle: "ana" }, text: "A new message while the analysis remains pinned.", ts: current.length + 1, }]); let state: ConversationPlan.State = { schemaVersion: 1, revision: 9, events: [], queue: [], - threads: [{ ...analysisHostThread, version }], + threads: [{ ...(linked ? analysisHostLinkedThread : analysisHostThread), version }], analysis: diagnostics ? [analysisHostRecord, { messageId: "host-retry", questionSetVersion: "fixture-v1", modelVersion: "fixture-m1", status: "failed", passes: [], eventIds: [], error: "Controlled analysis failure.", @@ -127,6 +162,6 @@ window.analysisHostFixture = { analysisHostPending.delete(kind); if (accepted) pending.resolve(); else pending.reject(new Error("Controlled held acknowledgement rejection")); }, - append() {}, version() {}, editable() {}, diagnostics() {}, + append() {}, version() {}, editable() {}, diagnostics() {}, linked() {}, }; `; diff --git a/e2e/source/analysis-host.native.ts b/e2e/source/analysis-host.native.ts index 8fc04e0a..b426085b 100644 --- a/e2e/source/analysis-host.native.ts +++ b/e2e/source/analysis-host.native.ts @@ -121,11 +121,16 @@ test("native selection refuses whole-message pinning and interactive controls re await page.waitForTimeout(180); await expect(analysis(page, "host-review")).toHaveCount(0); await page.evaluate(() => window.getSelection()!.removeAllRanges()); - await anchor.getByRole("button", { name: "Review 1 excerpt", exact: true }).click(); + await expect(anchor.getByRole("button", { name: "Review 1 excerpt", exact: true })) + .toHaveCount(0); + await anchor.getByRole("button", { name: /^Analysis for message:/ }).press("Enter"); await expect(analysis(page, "host-review")).toBeVisible(); - await expect( - analysis(page, "host-review").getByRole("button", { name: "Add to card", exact: true }), - ).toBeFocused(); + let correction = analysis(page, "host-review").getByRole("button", { + name: "Add to card", + exact: true, + }); + await correction.focus(); + await expect(correction).toBeFocused(); expect(errors).toEqual([]); }); diff --git a/e2e/source/playwright.config.ts b/e2e/source/playwright.config.ts index 8c425901..48bda0f2 100644 --- a/e2e/source/playwright.config.ts +++ b/e2e/source/playwright.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ "conversation-announcements.native.ts", "analysis-host.native.ts", "diagnostic-controls.native.ts", + "analysis-host-linked.native.ts", "analysis-host-retries.native.ts", "decision-reader.native.ts", "collapse-deletion.native.ts", diff --git a/scripts/design-contract/exceptions/dynamic-web.json b/scripts/design-contract/exceptions/dynamic-web.json index 0c76ba41..a1fa9df6 100644 --- a/scripts/design-contract/exceptions/dynamic-web.json +++ b/scripts/design-contract/exceptions/dynamic-web.json @@ -333,6 +333,6 @@ 1 ] ], - "sourceHash": "f3695d501f5aedc2cc46c76134aa402e1608fa9b7b5f233adae9c855ab137516" + "sourceHash": "462f224addb47a1a0945bf8c49b49fb35f70fac66e92b81a8ed544a6c4b49ea0" } ]