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"
}
]