Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions apps/web/src/conversation-plan/markers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
});
61 changes: 26 additions & 35 deletions apps/web/src/conversation-plan/markers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -222,35 +216,7 @@ export function MessageMarkers(
if (state && links.length === 0 && !analysis && jobs.length === 0) return null;

return (
<div
className="mt-1 flex flex-wrap gap-1 text-sm"
data-message-markers={messageId}
>
{links.slice(0, 3).map(link => (
<button
aria-label={`${link.label}: show card for “${link.source.quote}”`}
className="rounded-full bg-inset px-2 py-0.5 text-xs text-text-secondary hover:bg-hover"
data-card-link={link.itemId}
data-card-thread={link.threadId}
key={`${link.itemId}-${link.source.start}-${link.source.end}`}
onClick={() => onCard(link)}
title={link.source.quote}
type="button"
>
{link.label}
</button>
))}
{reviewableCount > 0 && (
<button
aria-controls={visible ? popoverId : undefined}
aria-expanded={visible}
className="rounded-full bg-inset px-2 py-0.5 text-xs text-text-secondary hover:bg-hover"
onClick={openAnalysis}
type="button"
>
Review {reviewableCount} excerpt{reviewableCount === 1 ? "" : "s"}
</button>
)}
<div data-message-markers={messageId}>
{hasDiagnostics && (
<button
aria-controls={visible ? popoverId : undefined}
Expand Down Expand Up @@ -320,6 +286,31 @@ export function MessageMarkers(
Close
</button>
</div>
{links.length > 0 && (
<div
aria-label="Linked decisions"
className="mt-2 flex flex-wrap gap-1"
role="group"
>
{links.slice(0, 3).map(link => (
<button
aria-label={`${link.label}: show card for “${link.source.quote}”`}
className="rounded-full bg-inset px-2 py-0.5 text-xs text-text-secondary hover:bg-hover"
data-card-link={link.itemId}
data-card-thread={link.threadId}
key={`${link.itemId}-${link.source.start}-${link.source.end}`}
onClick={() => {
closeAnalysis();
onCard(link);
}}
title={link.source.quote}
type="button"
>
{link.label}
</button>
))}
</div>
)}
<AnalysisOverview
analysis={analysis}
canEdit={canEdit}
Expand Down
11 changes: 5 additions & 6 deletions e2e/conversation-plan-stress.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,22 +361,21 @@ test("a writer can apply a focused Copilot subspan as an option", async ({ join,
let reader = readerContext.page;
await openJevWire(reader, room);
let readerMessage = reader.locator(`[data-chat-message-id="${messageId}"]`);
let readerReview = readerMessage.getByRole("button", { name: "Review 1 excerpt" });
await expect(readerReview).toBeVisible();
await readerReview.click();
await expect(readerMessage.getByRole("button", { name: "Review 1 excerpt" })).toHaveCount(0);
await readerMessage.getByRole("button", { name: /Analysis for message/ }).press("Enter");
let readerPanel = reader.locator(`[data-analysis-message="${messageId}"]`);
await expect(readerPanel.getByRole("button", { name: "Add to card" })).toBeDisabled();
await expect(readerPanel.getByRole("button", { name: "Add excerpt" })).toHaveCount(0);

let writerMessage = ana.locator(`[data-chat-message-id="${messageId}"]`);
let review = writerMessage.getByRole("button", { name: "Review 1 excerpt" });
await expect(review).toBeVisible();
await expect(writerMessage.getByRole("button", { name: "Review 1 excerpt" })).toHaveCount(0);
let writerPanel = ana.locator(`[data-analysis-message="${messageId}"]`);
await writerMessage.hover();
await expect(writerPanel).toHaveCount(0);
await review.click();
await writerMessage.getByRole("button", { name: /Analysis for message/ }).press("Enter");
await expect(writerPanel).toBeVisible();
let addToCard = writerPanel.getByRole("button", { name: "Add to card" });
await addToCard.focus();
await expect(addToCard).toBeFocused();
await addToCard.click();
await expect(writerPanel.getByLabel("Contribution type")).toBeFocused();
Expand Down
23 changes: 18 additions & 5 deletions e2e/conversation-plan-ui.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,16 @@ test("a chat question becomes an inline decision card", async ({ join, room }) =
await expect(card.locator("fieldset")).toHaveCount(1);
await expect(card.getByRole("list")).toHaveCount(0);
await expect(page.locator(`[data-chat-message-id="${questionMessage}"] [data-message-markers]`))
.toBeVisible();
.toBeAttached();
let compound = page.locator(`[data-chat-message-id="${compoundMessage}"]`);
await expect(compound.locator(`[data-card-link="${option.id}"]`)).toHaveText("Proposal");
await compound.locator(`[data-card-link="${option.id}"]`).click();
await expect(compound.locator(`[data-card-link="${option.id}"]`)).toHaveCount(0);
let inspect = compound.getByRole("button", { name: /Analysis for message/ });
await inspect.press("Enter");
let analysis = page.getByLabel("Message analysis");
let cardLink = analysis.locator(`[data-card-link="${option.id}"]`);
await expect(cardLink).toHaveText("Proposal");
await cardLink.click();
await expect(analysis).toHaveCount(0);
await expect(card).toBeFocused();
await card.getByRole("button", { name: "Show source in chat" }).click();
await expect(page.locator(`[data-chat-message-id="${questionMessage}"]`))
Expand All @@ -173,9 +179,7 @@ test("a chat question becomes an inline decision card", async ({ join, room }) =
true,
);

let inspect = compound.getByRole("button", { name: /Analysis for message/ });
await inspect.focus();
let analysis = page.getByLabel("Message analysis");
await expect(analysis).toHaveCount(0);
await inspect.press("Enter");
await expect(analysis).toContainText("findings applied");
Expand All @@ -189,6 +193,15 @@ test("a chat question becomes an inline decision card", async ({ join, room }) =
await page.keyboard.press("Escape");
await expect(analysis).toHaveCount(0);
await expect(inspect).toBeFocused();
await inspect.press("Enter");
let close = analysis.getByRole("button", { name: "Close analysis" });
await close.focus();
await close.press("Tab");
let keyboardCardLink = analysis.locator(`[data-card-link="${option.id}"]`);
await expect(keyboardCardLink).toBeFocused();
await keyboardCardLink.press("Enter");
await expect(analysis).toHaveCount(0);
await expect(card).toBeFocused();
let unlinkedMessage = await sendChat(page, "Maybe that?");
await expect.poll(async () =>
(await wireState(page))?.analysis.find(item => item.messageId === unlinkedMessage)?.status
Expand Down
91 changes: 91 additions & 0 deletions e2e/source/analysis-host-linked.native.ts
Original file line number Diff line number Diff line change
@@ -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([]);
},
);
6 changes: 4 additions & 2 deletions e2e/source/analysis-host-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
39 changes: 37 additions & 2 deletions e2e/source/analysis-host.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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: [{
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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() {},
};
`;
13 changes: 9 additions & 4 deletions e2e/source/analysis-host.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});

Expand Down
1 change: 1 addition & 0 deletions e2e/source/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion scripts/design-contract/exceptions/dynamic-web.json
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,6 @@
1
]
],
"sourceHash": "f3695d501f5aedc2cc46c76134aa402e1608fa9b7b5f233adae9c855ab137516"
"sourceHash": "462f224addb47a1a0945bf8c49b49fb35f70fac66e92b81a8ed544a6c4b49ea0"
}
]
Loading