From a804d47db9595c699e7cee714ce197ba074a3182 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 26 Oct 2026 17:00:01 -0400 Subject: [PATCH 1/4] Inbox: show a markdown choice label's words in folded lines and answer rows A choice label is the agent's inline markdown ([15% off](forecast-15.html), inline code, bold). The live card draws it as markdown, but the folded line, the answers a sent reply carried, the reply box's answers, the open questions Draft, a replaced card's unsent pick and decision mode's stamp showed the raw markdown. They now show the label's words (plainLabel). The reply the agent reads and read_thread keep each label exactly as written. --- .github/workflows/test.yml | 1 + packages/inbox/choiceLabels.test.tsx | 267 ++++++++++++++++++ .../inbox/components/ReplacedQuestion.tsx | 5 +- packages/inbox/decisions/model.ts | 4 +- packages/inbox/decisionsModel.test.ts | 8 + packages/inbox/folding.test.ts | 27 +- packages/inbox/folding.ts | 21 +- tests/e2e/inbox-choice-labels.spec.ts | 254 +++++++++++++++++ tests/e2e/playwright.config.ts | 4 +- 9 files changed, 583 insertions(+), 8 deletions(-) create mode 100644 packages/inbox/choiceLabels.test.tsx create mode 100644 tests/e2e/inbox-choice-labels.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75833aabf5..5aa2638781 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -160,6 +160,7 @@ jobs: packages/inbox/decisionMode.test.tsx packages/inbox/decisionCardContract.test.tsx packages/inbox/threadFolding.test.tsx + packages/inbox/choiceLabels.test.tsx packages/inbox/pasteTarget.test.ts packages/inbox/seenOnSight.test.tsx packages/inbox/projectList.test.tsx diff --git a/packages/inbox/choiceLabels.test.tsx b/packages/inbox/choiceLabels.test.tsx new file mode 100644 index 0000000000..50049d266b --- /dev/null +++ b/packages/inbox/choiceLabels.test.tsx @@ -0,0 +1,267 @@ +/** + * A choice label is the agent's inline markdown (`- [ ] [15% off](forecast-15.html)`, + * inline code, bold). The live card draws it as markdown; every line or row + * that draws a pick as text shows the label's words ("15% off"), never its + * markdown: the folded line (and its screen-reader label), the answers a sent + * reply carried, the answers that go with the reply being written, the open + * questions list's Draft, and a replaced card's unsent pick. + * + * DOM-gated: runs under DOM_TESTS=1 (happy-dom), from the test.yml DOM list. + */ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseInboxQuestionBlocks } from '@plannotator/core/inbox-questions'; +import { formatQuestionAnswersSection, type QuestionAnswer } from '@plannotator/core/question-block'; +import type { InboxAuthor, InboxMessageWire, InboxProject, InboxQuestion, InboxThread } from '@plannotator/core/inbox-types'; + +const hasDom = typeof document !== 'undefined'; +// Viewer pulls in web-highlighter, which reads `window` at module-eval time. +const { ThreadPane } = (hasDom ? await import('./components/Thread') : {}) as typeof import('./components/Thread'); + +const PROJECT: InboxProject = { id: 'prj_1', key: 'promo-abcdef', name: 'promo', root: '/work/promo', created_at: '2026-10-26T09:00:00.000Z' }; +const AGENT: Extract = { kind: 'agent', host: 'claude-code', session: 'ses_a', name: null }; +const id = (n: number) => `msg_01M5CL0000000000000000000${n}`; +const [M1, R1, M2] = [1, 2, 3].map(id) as [string, string, string]; + +const LINK = '[15% off](forecast-15.html)'; +const ASK = [ + 'Two calls on the spring promo.', + '', + ':::question', + 'Which discount should the promo run?', + '', + `- [ ] ${LINK}`, + '- [ ] `FLAT10` code', + '- [ ] **20%** for members', + ':::', + '', + ':::question-multi', + 'Which channels announce it?', + '', + '- [ ] **Email**', + '- [ ] `push` notification', + '- [ ] [In-app banner](forecast-15.html)', + ':::', +].join('\n'); +const PICKS = [[LINK], ['**Email**', '`push` notification', '[In-app banner](forecast-15.html)']]; +const LATEST = ['The forecast is updated.', '', ':::question', 'Who gets the first send?', '', '- [ ] [Beta list](forecast-15.html)', '- [ ] `everyone`', ':::'].join('\n'); + +function questionsOf(messageId: string, body: string, patch: (q: InboxQuestion) => Partial = () => ({})): InboxQuestion[] { + return parseInboxQuestionBlocks(body).map((q) => { + const base: InboxQuestion = { + key: q.key, + position: q.position, + kind: q.kind, + prompt: q.prompt, + context: q.parsed.context, + choices: q.parsed.choices, + recommendation: null, + suggested_text: null, + stopped: q.stopped, + holds_up: q.holds_up, + asked_by_agent_id: AGENT.session, + orphaned: false, + state: 'open', + answer: null, + revision: 0, + sent_revision: 0, + picked_by: null, + picked_at: null, + sent_reply_id: null, + message_id: messageId, + superseded_by: null, + }; + return { ...base, ...patch(base) }; + }); +} + +const answerFor = (q: InboxQuestion, selected: string[]): QuestionAnswer => ({ v: 1, key: q.key, kind: q.kind === 'multi' ? 'multi' : 'single', prompt: q.prompt, selected }); + +function message(messageId: string, author: InboxAuthor, body: string, seq: number, questions: InboxQuestion[], extra: Partial = {}): InboxMessageWire { + return { + id: messageId, + project_id: PROJECT.id, + thread_id: M1, + reply_to: messageId === M1 ? null : M1, + author, + subject: messageId === M1 ? 'Spring promo' : null, + body, + created_at: `2026-10-26T1${seq}:15:00.000Z`, + resolved_at: null, + idempotency_key: null, + seq, + ...(questions.length > 0 ? { questions } : {}), + ...extra, + }; +} + +/** Asked, answered by a reply the person sent, then one more question: picked, not sent. The look is at the reply. */ +function sample(): InboxThread { + const asked = questionsOf(M1, ASK); + const answers = asked.map((q, i) => answerFor(q, PICKS[i]!)); + const sent = asked.map((q, i) => ({ ...q, state: 'sent' as const, answer: answers[i]!, revision: 1, sent_revision: 1, sent_reply_id: R1 })); + // The reply's body as the store writes it for the agent: each label exactly as written. + const section = formatQuestionAnswersSection( + asked.map((q, i) => ({ key: q.key, number: i + 1, prompt: q.prompt, recommendedLabels: [], settled: false })), + answers, + ).trimEnd(); + const replyBody = `Answered 2 questions.\n\n${section}`; + const latest = questionsOf(M2, LATEST, (q) => ({ state: 'picked', revision: 1, picked_at: '2026-10-26T13:20:00.000Z', answer: answerFor(q, ['[Beta list](forecast-15.html)']) })); + return { + thread_id: M1, + project: PROJECT, + subject: 'Spring promo', + thread_name: null, + resolved_at: null, + messages: [ + message(M1, AGENT, ASK, 1, sent, { person_seen_seq: 2 }), + message(R1, { kind: 'person' }, replyBody, 2, []), + message(M2, AGENT, LATEST, 3, latest), + ], + }; +} + +let root: Root | null = null; +let host: HTMLElement | null = null; +let savedFetch: typeof fetch; + +beforeEach(() => { + if (!hasDom) return; + savedFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const path = String(input); + if (path.includes('/attachments')) return Response.json({ serverSession: 's', attachments: [], annotations: [] }); + if (path.includes('/sessions')) return Response.json({ serverSession: 's', home: '/home/me', project: PROJECT, sessions: [] }); + return Response.json({}); + }) as typeof fetch; +}); + +afterEach(() => { + if (!hasDom) return; + act(() => root?.unmount()); + host?.remove(); + document.querySelectorAll('[data-open-questions-list]').forEach((n) => n.closest('.ib-oq-pos')?.remove()); + root = null; + host = null; + globalThis.fetch = savedFetch; +}); + +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function until(check: () => boolean, what: string) { + for (let i = 0; i < 60; i += 1) { + if (check()) return; + await settle(); + } + throw new Error(`timed out waiting for ${what}`); +} + +async function render(thread: InboxThread) { + host = document.createElement('div'); + host.className = 'pn-inbox'; + document.body.appendChild(host); + act(() => { + root = createRoot(host!); + root.render( + {}} + onChanged={async () => {}} + onClose={() => {}} + file={null} + onOpenFile={() => {}} + onOpenGuide={() => {}} + />, + ); + }); + await settle(); + return host; +} + +const article = (el: HTMLElement, messageId: string) => el.querySelector(`[data-message-article="${messageId}"]`)!; +const text = (node: Element | null | undefined) => node?.textContent?.replace(/\s+/g, ' ').trim() ?? ''; + +/** Base UI opens on the pointer's press, as a person's click does. */ +async function press(el: HTMLElement) { + await act(async () => { + el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerType: 'mouse' })); + el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); + el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, button: 0, pointerType: 'mouse' })); + el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, button: 0 })); + el.click(); + }); +} + +const ANSWERS_LINE = 'Which discount should the promo run → 15% off · Which channels announce it → Email, push notification, In-app banner'; +const ANSWERS_SAID = 'Which discount should the promo run: 15% off; Which channels announce it: Email, push notification, In-app banner'; + +test.skipIf(!hasDom)("the folded lines say the picked labels' words, never their markdown", async () => { + const el = await render(sample()); + for (const messageId of [M1, R1]) { + const line = article(el, messageId); + expect(line.hasAttribute('data-folded')).toBe(true); + expect(text(line.querySelector('[data-snippet="answers"]'))).toBe(ANSWERS_LINE); + expect(line.querySelector('[data-fold-head]')!.getAttribute('aria-label')!.endsWith(ANSWERS_SAID)).toBe(true); + } +}); + +test.skipIf(!hasDom)('the answers a sent reply carried, opened: the labels as words', async () => { + const el = await render(sample()); + act(() => article(el, R1).querySelector('[data-fold-head]')!.click()); + await until(() => article(el, R1).querySelectorAll('[data-sent-answer]').length === 2, 'the sent answers'); + const rows = [...article(el, R1).querySelectorAll('[data-sent-answer]')]; + expect(rows.map(text)).toEqual(['Q1 Which discount should the promo run? 15% off', 'Q2 Which channels announce it? Email, push notification, In-app banner']); + expect(rows[0]!.getAttribute('aria-label')).toBe('Q1, Which discount should the promo run?: 15% off. Show the question.'); + // The reply's own body (the agent's copy) is not drawn: its markdown never shows. + expect(article(el, R1).textContent).not.toContain(']('); +}); + +test.skipIf(!hasDom)('the live card draws the label as markdown: a link and code', async () => { + const el = await render(sample()); + const card = article(el, M2).querySelector('fieldset[data-question-key]')!; + const options = [...card.querySelectorAll('[data-question-option]')]; + // The link drawn by the card's link handling (a file the message did not attach is inert text). + expect(text(options[0]!.querySelector('[data-local-link]'))).toBe('Beta list'); + expect(text(options[1]!.querySelector('code'))).toBe('everyone'); + expect(card.textContent).not.toContain(']('); +}); + +test.skipIf(!hasDom)("the open questions list's Draft and the answers going with the reply: the pick as words", async () => { + const el = await render(sample()); + await press(el.querySelector('[data-open-questions]')!); + await until(() => !!document.querySelector('[data-open-questions-list]'), 'the list'); + const row = document.querySelector('[data-open-questions-list] .ib-oq-row')!; + expect(text(row.querySelector('.ib-oq-draft'))).toBe('Draft · Beta list'); + act(() => (document.activeElement as HTMLElement | null)?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); + const edit = [...el.querySelectorAll('.ib-pfoot button')].find((b) => ['Reply', 'Edit draft'].includes(text(b)))!; + act(() => edit.click()); + await until(() => !!el.querySelector('[data-reply-answer]'), 'the answers going with the reply'); + expect(text(el.querySelector('[data-reply-answer]'))).toBe('Q1 Who gets the first send? Beta list'); +}); + +test.skipIf(!hasDom)("a replaced card's unsent pick: the label's words", async () => { + const OLD = `Promo.\n\n:::question\nWhich discount should the promo run?\n\n- [ ] ${LINK}\n- [ ] No discount\n:::`; + const [old] = questionsOf(M1, OLD, (q) => ({ + state: 'superseded', + revision: 1, + picked_at: '2026-10-26T11:20:00.000Z', + answer: answerFor(q, [LINK]), + superseded_by: { message_id: M2, key: null }, + })); + const thread: InboxThread = { + thread_id: M1, + project: PROJECT, + subject: 'Spring promo', + thread_name: null, + resolved_at: null, + messages: [message(M1, AGENT, OLD, 1, [old!]), message(M2, AGENT, 'The promo is off; neither question applies.', 2, [])], + }; + const el = await render(thread); + expect(text(el.querySelector('[data-unsent-pick] b'))).toBe('15% off'); +}); diff --git a/packages/inbox/components/ReplacedQuestion.tsx b/packages/inbox/components/ReplacedQuestion.tsx index 276b6a6616..3728f27e01 100644 --- a/packages/inbox/components/ReplacedQuestion.tsx +++ b/packages/inbox/components/ReplacedQuestion.tsx @@ -19,6 +19,7 @@ import { QuestionContext } from '@plannotator/ui/components/blocks/QuestionConte import type { InboxAuthor, InboxMessageWire, InboxQuestion } from '@plannotator/core/inbox-types'; import type { QuestionAnswer } from '@plannotator/core/question-block'; import { agentName, whenTime } from '../format'; +import { plainLabel } from '../folding'; import { Icon } from '../icons'; /** What replaced a question, read from its thread. */ @@ -32,10 +33,10 @@ export interface Replacement { /** Where a jump lands: one question's card, or a message. */ export type JumpTarget = { kind: 'question'; messageId: string; key: string } | { kind: 'message'; messageId: string }; -/** The answer's words, as the card's "Your pick" shows them. */ +/** The answer's words, as the card's "Your pick" shows them; a picked label as its plain words (`plainLabel`). */ export function pickWords(answer: QuestionAnswer): string { if (answer.skipped) return 'Skipped'; - const parts = [...answer.selected]; + const parts = answer.selected.map(plainLabel); if (answer.other) parts.push(answer.other); if (answer.text) parts.push(answer.text); return parts.join(', '); diff --git a/packages/inbox/decisions/model.ts b/packages/inbox/decisions/model.ts index 9c6950af80..9da0282293 100644 --- a/packages/inbox/decisions/model.ts +++ b/packages/inbox/decisions/model.ts @@ -10,6 +10,7 @@ import type { InboxDecision, InboxListRow, InboxMessageWire, InboxQuestion, Inbo import { readInboxQuestionKind, inboxAgentName } from '@plannotator/core/inbox-types'; import { capQuestionPrompt, type QuestionAnswer } from '@plannotator/core/question-block'; import { sendTargetsOf, type SendTarget } from '../sendTargets'; +import { plainLabel } from '../folding'; /** Why a decision left the projection while the queue held it. */ export type DecisionGone = 'sent' | 'replaced' | 'closed'; @@ -426,7 +427,8 @@ export function skipAnswer(question: InboxQuestion, note: string): QuestionAnswe export function answerWords(answer: QuestionAnswer | null): string { if (!answer) return 'No answer'; if (answer.skipped) return 'Skipped'; - const parts = [...answer.selected]; + // A picked label as its plain words (`plainLabel`): "15% off", not its markdown. + const parts = answer.selected.map(plainLabel); if (answer.other?.trim()) parts.push(answer.other.trim()); if (answer.text?.trim()) parts.push(answer.text.trim()); const words = parts.join(', ') || (answer.note ? 'A note' : 'No answer'); diff --git a/packages/inbox/decisionsModel.test.ts b/packages/inbox/decisionsModel.test.ts index 483b164ea8..a0b996dab1 100644 --- a/packages/inbox/decisionsModel.test.ts +++ b/packages/inbox/decisionsModel.test.ts @@ -232,6 +232,14 @@ describe('answers', () => { expect(swipeChoices(A1.question)).toEqual(['Yes', 'No']); expect(swipeChoices({ ...A1.question, kind: 'multi' })).toBeNull(); }); + + test("a picked label's markdown reads as its words on the stamp; the answer keeps the label as written", () => { + const linked = { ...A1.question, choices: [{ label: '[15% off](forecast-15.html)' }, { label: '`FLAT10` code' }] } as InboxQuestion; + const answer = choiceAnswer(linked, ['[15% off](forecast-15.html)'], ''); + expect(answer.selected).toEqual(['[15% off](forecast-15.html)']); + expect(answerWords(answer)).toBe('15% off'); + expect(answerWords({ ...answer, selected: ['**20%** for members', '`FLAT10` code'] })).toBe('20% for members, FLAT10 code'); + }); }); describe('the contract lines (approval "inbox-decision-mode", agent contract lines)', () => { diff --git a/packages/inbox/folding.test.ts b/packages/inbox/folding.test.ts index 806facd033..56d5d57185 100644 --- a/packages/inbox/folding.test.ts +++ b/packages/inbox/folding.test.ts @@ -6,7 +6,7 @@ import { expect, test } from 'bun:test'; import type { InboxAuthor, InboxMessageWire, InboxThread } from '@plannotator/core/inbox-types'; import { QUESTION_ANSWERS_HEADING } from '@plannotator/core/question-block'; -import { SNIPPET_PROMPT_MAX, cleanInline, cutAtWord, firstWords, foldSnippet, lookBoundary, newSinceLook, planFolds, replyWords } from './folding'; +import { SNIPPET_PROMPT_MAX, cleanInline, cutAtWord, firstWords, foldSnippet, lookBoundary, newSinceLook, plainLabel, planFolds, replyWords, snippetText } from './folding'; const AGENT: InboxAuthor = { kind: 'agent', host: 'claude-code', session: 'ses_a', name: null }; const PERSON: InboxAuthor = { kind: 'person' }; @@ -126,6 +126,31 @@ test('a long prompt is cut at a word near 40 characters, so the answer after it expect(cutAtWord('short', 40)).toBe('short'); }); +test("a picked label's markdown (a link, code, bold) reads as its words on the line; Other and free text stay as typed", () => { + const prompt = 'Which discount should the promo run?'; + const message = msg('m1', AGENT, 2, { + body: `:::question-multi\n${prompt}\n\n- [ ] [15% off](forecast-15.html)\n- [ ] \`FLAT10\` code\n- [ ] **20%** for members\n- [ ] No discount\n:::`, + questions: [ + { + key: 'q-1', + prompt, + state: 'sent', + orphaned: false, + answer: { v: 1, key: 'q-1', kind: 'multi', prompt, selected: ['[15% off](forecast-15.html)', '`FLAT10` code', '**20%** for members', 'No discount'], other: 'a **custom** deal' }, + } as never, + ], + }); + const snippet = foldSnippet(message); + expect(snippet).toEqual({ + kind: 'answers', + parts: [{ prompt: 'Which discount should the promo run', full: 'Which discount should the promo run', answer: '15% off, FLAT10 code, 20% for members, No discount, a **custom** deal' }], + }); + expect(snippetText(snippet)).toBe('Which discount should the promo run: 15% off, FLAT10 code, 20% for members, No discount, a **custom** deal'); + // A label that is only a picture reads as its alt text, never as nothing. + expect(plainLabel('![The dark mock](dark.png)')).toBe('The dark mock'); + expect(plainLabel('snake_case and 2 * 3')).toBe('snake_case and 2 * 3'); +}); + test("a reply of skips or of answers and skips: the store's first line is dropped too", () => { expect(replyWords('Skipped 3 questions.\n\nRedo the design.')).toBe('Redo the design.'); expect(replyWords('Answered 1 question, skipped 2.\n\nRedo the design.')).toBe('Redo the design.'); diff --git a/packages/inbox/folding.ts b/packages/inbox/folding.ts index 0bc5c43f97..316a14945c 100644 --- a/packages/inbox/folding.ts +++ b/packages/inbox/folding.ts @@ -109,10 +109,14 @@ export function planFolds(messages: readonly InboxMessageWire[], fresh: Readonly return { folded, hidden }; } -/** The words an answer reads as: "Pagefind", "Guides, API reference", "skipped". */ +/** + * The words an answer reads as: "Pagefind", "Guides, API reference", "skipped". + * A picked label shows as its plain words (`plainLabel`); Other and free text + * are the person's own words, shown as typed. + */ export function answerWords(answer: QuestionAnswer): string { if (answer.skipped) return 'skipped'; - const parts = [...answer.selected]; + const parts = answer.selected.map(plainLabel); if (answer.other) parts.push(answer.other); if (answer.text) parts.push(answer.text); return parts.join(', '); @@ -255,6 +259,19 @@ export function cleanInline(text: string): string { return out.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => code[Number(i)] ?? ''); } +/** + * A picked choice's label as plain words. A label is the agent's inline + * markdown (a link, code, bold): a line or row that draws it as text shows + * "15% off", never `[15% off](forecast-15.html)`. Display only: the stored + * answer and the reply the agent reads keep the label exactly as written, so + * the agent matches it to its choice. A label that is only an image reads as + * its alt text, so the pick never shows as nothing. + */ +export function plainLabel(label: string): string { + const plain = (text: string) => cleanInline(text).replace(/\s+/g, ' ').trim(); + return plain(label) || plain(label.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')) || label.trim(); +} + /** * The first words of a message's markdown as plain text: question blocks, * fences, tables, images and raw HTML dropped, link and emphasis marks diff --git a/tests/e2e/inbox-choice-labels.spec.ts b/tests/e2e/inbox-choice-labels.spec.ts new file mode 100644 index 0000000000..705c941643 --- /dev/null +++ b/tests/e2e/inbox-choice-labels.spec.ts @@ -0,0 +1,254 @@ +/** + * A question's choice label is the agent's inline markdown: a link to a file + * it attached (`- [ ] [15% off](forecast-15.html)`), inline code, bold. The + * live card draws it as markdown (the link opens the file beside the thread); + * every line or row that draws a pick as text shows its words ("15% off"), + * never the markdown: the folded line, the answers a sent reply carried, the + * open questions list's Draft, the answers that go with the reply. The reply + * the agent reads keeps each label exactly as written, so the agent matches it + * to its choice. + * + * Proved in a real browser against the compiled binary: Claude Code asks + * through `plannotator inbox mcp` (scripts/inbox-sim.ts), the person answers + * through the window's routes. Build the binary first (see inbox.spec.ts), + * then `bun run test:e2e:inbox`. PNGs light and dark at 1440 by 900, and on a + * phone, land in .local/proof/choice-labels/ (or + * PLANNOTATOR_E2E_PROOF_DIR/choice-labels). + */ + +import { expect, test, devices, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { SimAgent, scratchProject } from '../../scripts/inbox-sim'; +import { inboxScratch, removeInboxScratch } from './inbox-scratch'; +import { NO_BROWSER } from '../helpers/no-browser'; + +const repo = resolve(__dirname, '../..'); +const binary = resolve(process.env.PLANNOTATOR_E2E_BINARY ?? join(repo, '.local/plannotator')); +const proofDir = join(resolve(process.env.PLANNOTATOR_E2E_PROOF_DIR ?? join(repo, '.local/proof')), 'choice-labels'); + +const LINK = '[15% off](forecast-15.html)'; +const ASK = [ + 'Two calls on the spring promo before I build the forecast.', + '', + ':::question', + 'Which discount should the promo run?', + '', + `- [ ] ${LINK}`, + '- [ ] `FLAT10` code', + '- [ ] **20%** for members', + '- [ ] No discount', + ':::', + '', + ':::question-multi', + 'Which channels announce it?', + '', + '- [ ] **Email**', + '- [ ] `push` notification', + '- [ ] [In-app banner](forecast-15.html)', + '- [ ] Blog post', + ':::', +].join('\n'); +const PICKS = [[LINK], ['**Email**', '`push` notification', '[In-app banner](forecast-15.html)']]; +/** The answers as a line shows them: the labels' words. */ +const ANSWERS_LINE = 'Which discount should the promo run → 15% off · Which channels announce it → Email, push notification, In-app banner'; + +const LATEST = [ + 'The forecast is updated for 15% off.', + '', + ':::question', + 'Who gets the first send?', + '', + '- [ ] [Beta list](forecast-15.html)', + '- [ ] `everyone`', + ':::', +].join('\n'); + +interface World { + url: string; + claude: SimAgent; + context: BrowserContext; + page: Page; + errors: string[]; + threadId: string; + ids: string[]; +} + +let world: World; + +async function shot(page: Page, name: string): Promise { + for (const scheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme: scheme, reducedMotion: 'reduce' }); + await page.waitForTimeout(200); + await page.screenshot({ path: join(proofDir, `${name}-${scheme}.png`) }); + } + await page.emulateMedia({ colorScheme: 'light', reducedMotion: 'no-preference' }); +} + +async function post(path: string, body: unknown): Promise> { + const response = await fetch(`${world.url.replace(/\/$/, '')}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + expect(response.status, await response.clone().text()).toBe(200); + return (await response.json()) as Record; +} + +type Sent = { message_id: string; thread_id: string; questions: { key: string; prompt: string; kind: string }[] }; +const answer = (q: Sent['questions'][number], selected: string[]) => ({ key: q.key, revision: 0, answer: { v: 1, key: q.key, kind: q.kind, prompt: q.prompt, selected } }); + +const pane = (page: Page = world.page) => page.locator('section.ib-pane'); +const article = (id: string, page?: Page) => pane(page).locator(`[data-message-article="${id}"]`); + +test.describe.configure({ mode: 'serial' }); + +test.beforeAll(async ({ browser }: { browser: Browser }) => { + expect(existsSync(binary), `build the binary first: ${binary}`).toBe(true); + rmSync(proofDir, { recursive: true, force: true }); + mkdirSync(proofDir, { recursive: true }); + const root = inboxScratch('plannotator-inbox-choice-labels-e2e-'); + // Nothing here may reach the real tailscale CLI: a trap fails loudly first on PATH. + const trap = join(root, 'bin'); + mkdirSync(trap); + writeFileSync(join(trap, 'tailscale'), "#!/bin/sh\necho 'inbox-choice-labels.spec: tailscale must not run' >&2\nexit 97\n"); + chmodSync(join(trap, 'tailscale'), 0o755); + const env = { + PATH: `${trap}:${process.env.PATH ?? ''}`, + HOME: root, + PLANNOTATOR_DATA_DIR: join(root, 'data'), + PLANNOTATOR_BROWSER: NO_BROWSER, + PLANNOTATOR_INBOX_CONNECT: '0', + PLANNOTATOR_UPDATE_CHECK: '0', + }; + const started = spawnSync(binary, ['inbox', '--background'], { env, encoding: 'utf8', timeout: 60_000 }); + expect(started.status, started.stderr).toBe(0); + const url = started.stdout.trim(); + const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: 'light' }); + const page = await context.newPage(); + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + const project = scratchProject(join(root, 'src'), 'promo'); + writeFileSync(join(project, 'forecast-15.html'), 'Forecast at 15% off

Forecast at 15% off

Revenue +8%.

\n'); + const claude = await SimAgent.connect({ binary, env, name: 'Claude Code', host: 'claude-code' }); + world = { url, claude, context, page, errors, threadId: '', ids: [] }; + + // 1. Claude Code asks; 2. the person answers both, no words of their own (the reply's line shows the answers). + const ask = (await claude.send({ project_path: project, subject: 'Spring promo', body: ASK, attachments: ['forecast-15.html'] })) as Sent; + world.threadId = ask.thread_id; + const reply = await post(`/api/inbox/messages/${ask.message_id}/reply`, { + idempotency_key: 'r1', + questions: ask.questions.map((q, i) => answer(q, PICKS[i]!)), + }); + await claude.readThread(ask.thread_id); + // The person looks; 3. one more message arrives, its question picked and not sent. + await post(`/api/inbox/threads/${ask.thread_id}/seen`, {}); + const latest = (await claude.send({ project_path: project, body: LATEST, reply_to: ask.message_id, attachments: ['forecast-15.html'] })) as Sent; + await post(`/api/inbox/messages/${latest.message_id}/picks`, { questions: [answer(latest.questions[0]!, ['[Beta list](forecast-15.html)'])] }); + world.ids = [ask.message_id, reply.reply.id, latest.message_id]; +}); + +test.afterAll(async () => { + try { + if (!world) return; + await world.claude.close().catch(() => {}); + await world.context.close(); + } finally { + await removeInboxScratch(); + } +}); + +test('the folded lines show the picked labels as words, never their markdown', async () => { + const { page, ids } = world; + await page.goto(world.url); + await page.locator(`.ib-lbody [data-thread-id="${world.threadId}"]`).click(); + await expect(article(ids[0]!)).toHaveAttribute('data-folded', ''); + await expect(article(ids[1]!)).toHaveAttribute('data-folded', ''); + await shot(page, '1-folded-lines'); + // The asking message and the reply that answered it: both lines show the answers. + for (const id of [ids[0]!, ids[1]!]) { + await expect(article(id).locator('[data-snippet="answers"]')).toHaveText(ANSWERS_LINE); + // The line's screen-reader label and tooltip read the same words. + await expect(article(id).locator('[data-fold-head]')).toHaveAttribute('aria-label', /Which discount should the promo run: 15% off; Which channels announce it: Email, push notification, In-app banner$/); + } + await article(ids[0]!).locator('[data-snippet="answers"]').hover(); + await expect(page.locator('[data-side].bg-popover')).toHaveText('Which discount should the promo run: 15% off; Which channels announce it: Email, push notification, In-app banner'); + await page.mouse.move(1, 1); +}); + +test('the answers a sent reply carried show the labels as words', async () => { + const { page, ids } = world; + await article(ids[1]!).locator('[data-fold-head]').click(); + await expect(article(ids[1]!)).toHaveAttribute('data-open', ''); + const rows = article(ids[1]!).locator('[data-sent-answer]'); + await expect(rows).toHaveCount(2); + await rows.first().scrollIntoViewIfNeeded(); + await shot(page, '2-sent-answers'); + await expect(rows).toHaveText([/^Q1\s+Which discount should the promo run\?\s+15% off$/, /^Q2\s+Which channels announce it\?\s+Email, push notification, In-app banner$/]); +}); + +test('the live card draws the label as markdown: the link opens the attached file', async () => { + const { ids } = world; + const card = article(ids[2]!).locator('fieldset[data-question-key]'); + const link = card.locator('[data-question-option]').filter({ hasText: 'Beta list' }).locator('a'); + await expect(link).toHaveText('Beta list'); + await expect(link).toHaveAttribute('data-local-link', 'open'); + await expect(card.locator('[data-question-option]').filter({ hasText: 'everyone' }).locator('code')).toHaveText('everyone'); + await link.click(); + // The second message's own copy of the file (the thread holds two). + await expect(world.page.locator('[data-attachment-pane]')).toHaveAttribute('aria-label', /^forecast-15\.html, file 2 of 2$/); +}); + +test("the open questions list's Draft and the reply's answers show the pick as words", async () => { + const { page } = world; + // The thread alone again, without the file beside it. + await page.goto(`${world.url}#thread=${world.threadId}`); + await expect(page.locator('[data-attachment-pane]')).toHaveCount(0); + const foot = pane().locator('.ib-pfoot'); + await foot.locator('[data-open-questions]').click(); + const list = page.locator('[data-open-questions-list]'); + await expect(list).toBeVisible(); + await shot(page, '3-open-questions'); + await expect(list.locator('.ib-oq-row').first()).toContainText('Draft · Beta list'); + await expect(list.locator('.ib-oq-row').first()).not.toContainText(']('); + await page.keyboard.press('Escape'); + await foot.getByRole('button', { name: /^(Edit draft|Edit the reply|Reply)$/ }).first().click(); + const answers = pane().locator('[data-reply-answers]'); + await expect(answers).toBeVisible(); + await answers.scrollIntoViewIfNeeded(); + await shot(page, '4-reply-answers'); + await expect(answers.locator('[data-reply-answer]')).toHaveText([/^Q1\s+Who gets the first send\?\s+Beta list$/]); +}); + +test('the reply the agent reads keeps each label exactly as written', async () => { + const response = await fetch(`${world.url.replace(/\/$/, '')}/api/inbox/threads/${world.threadId}`); + const { thread } = (await response.json()) as { thread: { messages: { id: string; body: string }[] } }; + const body = thread.messages.find((m) => m.id === world.ids[1])!.body; + expect(body).toContain(`Answer: ${LINK}`); + expect(body).toContain('- **Email**\n- `push` notification\n- [In-app banner](forecast-15.html)'); + const read = JSON.stringify(await world.claude.readThread(world.threadId)); + expect(read).toContain(JSON.stringify(LINK).slice(1, -1)); + expect(world.errors).toEqual([]); +}); + +test('phone: the folded line shows the words', async ({ browser }: { browser: Browser }) => { + const { defaultBrowserType: _browser, ...phone } = devices['iPhone 15']; + const context = await browser.newContext({ ...phone, colorScheme: 'light' }); + try { + const page = await context.newPage(); + await page.goto(`${world.url}#thread=${world.threadId}`); + const line = article(world.ids[0]!, page).locator('[data-snippet="answers"]'); + await expect(line).toBeVisible(); + await shot(page, '5-phone-folded'); + await expect(line).toHaveText(ANSWERS_LINE); + await article(world.ids[1]!, page).locator('[data-fold-head]').click(); + const rows = article(world.ids[1]!, page).locator('[data-sent-answer]'); + await expect(rows).toHaveCount(2); + await rows.first().scrollIntoViewIfNeeded(); + await shot(page, '6-phone-sent-answers'); + await expect(rows.first()).toContainText('15% off'); + await expect(rows.first()).not.toContainText(']('); + } finally { + await context.close(); + } +}); diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 954b103eb7..43a96ea8df 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -3,7 +3,7 @@ * binary (`plannotator inbox`), under a temp PLANNOTATOR_DATA_DIR: the window * (inbox.spec.ts), the onboarding tour (inbox-tour.spec.ts), notifications and * guided reviews (inbox-guides.spec.ts), New message - * (inbox-new-message.spec.ts), hover cards for issue, PR and MR links (inbox-ref-cards.spec.ts), compose: a new message to a connected agent (inbox-compose.spec.ts), the skill picker in compose opening at the caret (inbox-compose-skill-picker.spec.ts), pasting images into compose and the reply box (inbox-paste.spec.ts), the question sound and its one-time dialog (inbox-sound.spec.ts), stepping through a thread's attachments (inbox-attachment-stepping.spec.ts), one viewer for a thread's images and files (inbox-image-viewer.spec.ts), stepping through files on a phone with the toolbar pill and a swipe (inbox-phone-stepping.spec.ts), a thread's Artifacts view (inbox-artifacts.spec.ts), one full-screen mode for files (inbox-full-view.spec.ts), the side-by-side view named and remembered (inbox-side-by-side.spec.ts), video attachments (inbox-video.spec.ts), replying when the asking session has ended (inbox-ended-session.spec.ts), Queued, Sending and Send now under a sent reply (inbox-send-now.spec.ts), replies to a busy agent: Undelivered N min and going in by themselves after 5 minutes (inbox-undelivered.spec.ts), a session that needs a restart after an update (inbox-restart.spec.ts), the order inside a section (inbox-order.spec.ts), replaced questions (inbox-replaced.spec.ts), Re-ask (inbox-reask.spec.ts), picture choices and the question contract lines (inbox-picture-choices.spec.ts), the Recommended pill staying on the tag (inbox-choice-pill.spec.ts), the open questions button in a thread's foot (inbox-open-questions.spec.ts), folding a thread (inbox-thread-folding.spec.ts), phones (inbox-phones.spec.ts), the sidebar (inbox-sidebar.spec.ts), the list column's resize edge (inbox-list-resize.spec.ts), images in messages (inbox-message-images.spec.ts), links to attached files (inbox-message-links.spec.ts), links inside an attached HTML page (inbox-html-links.spec.ts), links in annotate's HTML file and live app surfaces (annotate-html-links.spec.ts), Back in a review of several files keeping the reader's place (annotate-bundle-back-scroll.spec.ts), Over your tailnet (inbox-tailscale.spec.ts), the update control (inbox-updates.spec.ts), Mark read and the Send that skips (inbox-mark-read.spec.ts), the thread foot shortening before it wraps (inbox-thread-foot.spec.ts), the thread header's order (inbox-thread-header.spec.ts), 👍 Looks good (inbox-ack.spec.ts), action items an agent needs you to go do (inbox-actions.spec.ts), the project menu (inbox-project-menu.spec.ts), the thread row's right-click menu (inbox-row-menu.spec.ts), pinned threads in the sidebar, on a desktop and an iPhone in WebKit (inbox-pins.spec.ts), the Board page (inbox-board.spec.ts), archived threads in Settings (inbox-archived.spec.ts), the row you open staying put and the settle (inbox-open-stays-put.spec.ts), a new message in the thread you have open (inbox-seen-on-sight.spec.ts), the agents on this computer (inbox-agents.spec.ts), the annotation toolstrip painting above the document in the Inbox and annotate (toolstrip-stacking.spec.ts), the toolstrip's label in both reading directions, in Chromium and in the webkit project (toolstrip-rtl.spec.ts), the window on a phone in WebKit and Chromium (inbox-mobile.spec.ts), Settings in sections (inbox-settings.spec.ts), the worst-case data at every width (inbox-worst-case.spec.ts), dictated (pasted) text landing in the field being written in (inbox-dictation-paste.spec.ts), the Soft look's polish pass (inbox-soft-polish.spec.ts), pulling a phone's list down to refresh it (inbox-pull-refresh.spec.ts), reply drafts and the Draft label (inbox-reply-drafts.spec.ts), picks made while writing a reply (inbox-picks-composing.spec.ts), tabs sharing one event stream (inbox-shared-events.spec.ts, and in WebKit inbox-shared-events-webkit.spec.ts), code review drawing diagrams with the engine it loads on demand (review-diagrams.spec.ts), comments on part of an image in annotate and plan review (image-region-comments.spec.ts), and the + * (inbox-new-message.spec.ts), hover cards for issue, PR and MR links (inbox-ref-cards.spec.ts), compose: a new message to a connected agent (inbox-compose.spec.ts), the skill picker in compose opening at the caret (inbox-compose-skill-picker.spec.ts), pasting images into compose and the reply box (inbox-paste.spec.ts), the question sound and its one-time dialog (inbox-sound.spec.ts), stepping through a thread's attachments (inbox-attachment-stepping.spec.ts), one viewer for a thread's images and files (inbox-image-viewer.spec.ts), stepping through files on a phone with the toolbar pill and a swipe (inbox-phone-stepping.spec.ts), a thread's Artifacts view (inbox-artifacts.spec.ts), one full-screen mode for files (inbox-full-view.spec.ts), the side-by-side view named and remembered (inbox-side-by-side.spec.ts), video attachments (inbox-video.spec.ts), replying when the asking session has ended (inbox-ended-session.spec.ts), Queued, Sending and Send now under a sent reply (inbox-send-now.spec.ts), replies to a busy agent: Undelivered N min and going in by themselves after 5 minutes (inbox-undelivered.spec.ts), a session that needs a restart after an update (inbox-restart.spec.ts), the order inside a section (inbox-order.spec.ts), replaced questions (inbox-replaced.spec.ts), Re-ask (inbox-reask.spec.ts), picture choices and the question contract lines (inbox-picture-choices.spec.ts), the Recommended pill staying on the tag (inbox-choice-pill.spec.ts), the open questions button in a thread's foot (inbox-open-questions.spec.ts), folding a thread (inbox-thread-folding.spec.ts), choice labels written in markdown (inbox-choice-labels.spec.ts), phones (inbox-phones.spec.ts), the sidebar (inbox-sidebar.spec.ts), the list column's resize edge (inbox-list-resize.spec.ts), images in messages (inbox-message-images.spec.ts), links to attached files (inbox-message-links.spec.ts), links inside an attached HTML page (inbox-html-links.spec.ts), links in annotate's HTML file and live app surfaces (annotate-html-links.spec.ts), Back in a review of several files keeping the reader's place (annotate-bundle-back-scroll.spec.ts), Over your tailnet (inbox-tailscale.spec.ts), the update control (inbox-updates.spec.ts), Mark read and the Send that skips (inbox-mark-read.spec.ts), the thread foot shortening before it wraps (inbox-thread-foot.spec.ts), the thread header's order (inbox-thread-header.spec.ts), 👍 Looks good (inbox-ack.spec.ts), action items an agent needs you to go do (inbox-actions.spec.ts), the project menu (inbox-project-menu.spec.ts), the thread row's right-click menu (inbox-row-menu.spec.ts), pinned threads in the sidebar, on a desktop and an iPhone in WebKit (inbox-pins.spec.ts), the Board page (inbox-board.spec.ts), archived threads in Settings (inbox-archived.spec.ts), the row you open staying put and the settle (inbox-open-stays-put.spec.ts), a new message in the thread you have open (inbox-seen-on-sight.spec.ts), the agents on this computer (inbox-agents.spec.ts), the annotation toolstrip painting above the document in the Inbox and annotate (toolstrip-stacking.spec.ts), the toolstrip's label in both reading directions, in Chromium and in the webkit project (toolstrip-rtl.spec.ts), the window on a phone in WebKit and Chromium (inbox-mobile.spec.ts), Settings in sections (inbox-settings.spec.ts), the worst-case data at every width (inbox-worst-case.spec.ts), dictated (pasted) text landing in the field being written in (inbox-dictation-paste.spec.ts), the Soft look's polish pass (inbox-soft-polish.spec.ts), pulling a phone's list down to refresh it (inbox-pull-refresh.spec.ts), reply drafts and the Draft label (inbox-reply-drafts.spec.ts), picks made while writing a reply (inbox-picks-composing.spec.ts), tabs sharing one event stream (inbox-shared-events.spec.ts, and in WebKit inbox-shared-events-webkit.spec.ts), code review drawing diagrams with the engine it loads on demand (review-diagrams.spec.ts), comments on part of an image in annotate and plan review (image-region-comments.spec.ts), and the * (inbox-new-message.spec.ts), hover cards for issue, PR and MR links (inbox-ref-cards.spec.ts), compose: a new message to a connected agent (inbox-compose.spec.ts), the skill picker in compose opening at the caret (inbox-compose-skill-picker.spec.ts), pasting images into compose and the reply box (inbox-paste.spec.ts), the question sound and its one-time dialog (inbox-sound.spec.ts), stepping through a thread's attachments (inbox-attachment-stepping.spec.ts), one viewer for a thread's images and files (inbox-image-viewer.spec.ts), stepping through files on a phone with the toolbar pill and a swipe (inbox-phone-stepping.spec.ts), a thread's Artifacts view (inbox-artifacts.spec.ts), one full-screen mode for files (inbox-full-view.spec.ts), the side-by-side view named and remembered (inbox-side-by-side.spec.ts), video attachments (inbox-video.spec.ts), replying when the asking session has ended (inbox-ended-session.spec.ts), Queued, Sending and Send now under a sent reply (inbox-send-now.spec.ts), replies to a busy agent: Undelivered N min and going in by themselves after 5 minutes (inbox-undelivered.spec.ts), a session that needs a restart after an update (inbox-restart.spec.ts), the order inside a section (inbox-order.spec.ts), replaced questions (inbox-replaced.spec.ts), Re-ask (inbox-reask.spec.ts), folding a thread (inbox-thread-folding.spec.ts), phones (inbox-phones.spec.ts), the sidebar (inbox-sidebar.spec.ts), the list column's resize edge (inbox-list-resize.spec.ts), images in messages (inbox-message-images.spec.ts), links to attached files (inbox-message-links.spec.ts), links inside an attached HTML page (inbox-html-links.spec.ts), links in annotate's HTML file and live app surfaces (annotate-html-links.spec.ts), Back in a review of several files keeping the reader's place (annotate-bundle-back-scroll.spec.ts), Over your tailnet (inbox-tailscale.spec.ts), the update control (inbox-updates.spec.ts), Mark read and the Send that skips (inbox-mark-read.spec.ts), the thread foot shortening before it wraps (inbox-thread-foot.spec.ts), the thread header's order (inbox-thread-header.spec.ts), 👍 Looks good (inbox-ack.spec.ts), action items an agent needs you to go do (inbox-actions.spec.ts), the project menu (inbox-project-menu.spec.ts), archived threads in Settings (inbox-archived.spec.ts), the row you open staying put and the settle (inbox-open-stays-put.spec.ts), a new message in the thread you have open (inbox-seen-on-sight.spec.ts), the agents on this computer (inbox-agents.spec.ts), the annotation toolstrip painting above the document in the Inbox and annotate (toolstrip-stacking.spec.ts), the toolstrip's label in both reading directions, in Chromium and in the webkit project (toolstrip-rtl.spec.ts), the window on a phone in WebKit and Chromium (inbox-mobile.spec.ts), Settings in sections (inbox-settings.spec.ts), the worst-case data at every width (inbox-worst-case.spec.ts), dictated (pasted) text landing in the field being written in (inbox-dictation-paste.spec.ts), component tooltips in place of native titles (inbox-tooltips.spec.ts), tagging agents in a reply or a comment (inbox-tags.spec.ts), reply drafts and the Draft label (inbox-reply-drafts.spec.ts), picks made while writing a reply (inbox-picks-composing.spec.ts), and the * surface a phone hosts, in WebKit (surface.spec.ts), and the VS Code Guides * webview's sanitizer and Mermaid path (vscode-guides-sanitize.spec.ts) and its answer @@ -14,7 +14,7 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: '.', - testMatch: ['inbox.spec.ts', 'inbox-first-run.spec.ts', 'inbox-tour.spec.ts', 'inbox-search.spec.ts', 'notifications.spec.ts', 'inbox-attachments.spec.ts', 'inbox-attachments-kinds.spec.ts', 'inbox-video.spec.ts', 'inbox-attachment-stepping.spec.ts', 'inbox-image-viewer.spec.ts', 'inbox-phone-stepping.spec.ts', 'inbox-artifacts.spec.ts', 'inbox-full-view.spec.ts', 'inbox-side-by-side.spec.ts', 'inbox-guides.spec.ts', 'inbox-new-message.spec.ts', 'inbox-compose.spec.ts', 'inbox-compose-skill-picker.spec.ts', 'inbox-paste.spec.ts', 'inbox-ended-session.spec.ts', 'inbox-send-now.spec.ts', 'inbox-undelivered.spec.ts', 'inbox-restart.spec.ts', 'inbox-order.spec.ts', 'inbox-replaced.spec.ts', 'inbox-reask.spec.ts', 'inbox-open-questions.spec.ts', 'inbox-decision-mode.spec.ts', 'inbox-picture-choices.spec.ts', 'inbox-choice-pill.spec.ts', 'inbox-thread-folding.spec.ts', 'inbox-phones.spec.ts', 'inbox-sidebar.spec.ts', 'inbox-list-resize.spec.ts', 'inbox-message-images.spec.ts', 'inbox-message-links.spec.ts', 'inbox-ref-cards.spec.ts', 'inbox-html-links.spec.ts', 'annotate-html-links.spec.ts', 'annotate-bundle-back-scroll.spec.ts', 'inbox-tailscale.spec.ts', 'inbox-updates.spec.ts', 'inbox-update-notice.spec.ts', 'inbox-sound.spec.ts', 'inbox-mark-read.spec.ts', 'inbox-thread-foot.spec.ts', 'inbox-explicit-skip.spec.ts', 'inbox-thread-header.spec.ts', 'inbox-ack.spec.ts', 'inbox-actions.spec.ts', 'inbox-project-menu.spec.ts', 'inbox-row-menu.spec.ts', 'inbox-pins.spec.ts', 'inbox-board.spec.ts', 'inbox-archived.spec.ts', 'inbox-open-stays-put.spec.ts', 'inbox-seen-on-sight.spec.ts', 'inbox-mobile.spec.ts', 'toolstrip-stacking.spec.ts', 'toolstrip-rtl.spec.ts', 'inbox-agents.spec.ts', 'inbox-settings.spec.ts', 'inbox-worst-case.spec.ts', 'inbox-dictation-paste.spec.ts', 'inbox-message-comments.spec.ts', 'inbox-soft-polish.spec.ts', 'inbox-tooltips.spec.ts', 'inbox-tags.spec.ts', 'inbox-refresh.spec.ts', 'inbox-pull-refresh.spec.ts', 'inbox-reply-drafts.spec.ts', 'inbox-picks-composing.spec.ts', 'inbox-shared-events.spec.ts', 'review-diagrams.spec.ts', 'annotate-ai-history.spec.ts', 'image-region-comments.spec.ts', 'surface.spec.ts', 'vscode-guides-sanitize.spec.ts', 'vscode-guides-answer.spec.ts'], + testMatch: ['inbox.spec.ts', 'inbox-first-run.spec.ts', 'inbox-tour.spec.ts', 'inbox-search.spec.ts', 'notifications.spec.ts', 'inbox-attachments.spec.ts', 'inbox-attachments-kinds.spec.ts', 'inbox-video.spec.ts', 'inbox-attachment-stepping.spec.ts', 'inbox-image-viewer.spec.ts', 'inbox-phone-stepping.spec.ts', 'inbox-artifacts.spec.ts', 'inbox-full-view.spec.ts', 'inbox-side-by-side.spec.ts', 'inbox-guides.spec.ts', 'inbox-new-message.spec.ts', 'inbox-compose.spec.ts', 'inbox-compose-skill-picker.spec.ts', 'inbox-paste.spec.ts', 'inbox-ended-session.spec.ts', 'inbox-send-now.spec.ts', 'inbox-undelivered.spec.ts', 'inbox-restart.spec.ts', 'inbox-order.spec.ts', 'inbox-replaced.spec.ts', 'inbox-reask.spec.ts', 'inbox-open-questions.spec.ts', 'inbox-decision-mode.spec.ts', 'inbox-picture-choices.spec.ts', 'inbox-choice-pill.spec.ts', 'inbox-thread-folding.spec.ts', 'inbox-choice-labels.spec.ts', 'inbox-phones.spec.ts', 'inbox-sidebar.spec.ts', 'inbox-list-resize.spec.ts', 'inbox-message-images.spec.ts', 'inbox-message-links.spec.ts', 'inbox-ref-cards.spec.ts', 'inbox-html-links.spec.ts', 'annotate-html-links.spec.ts', 'annotate-bundle-back-scroll.spec.ts', 'inbox-tailscale.spec.ts', 'inbox-updates.spec.ts', 'inbox-update-notice.spec.ts', 'inbox-sound.spec.ts', 'inbox-mark-read.spec.ts', 'inbox-thread-foot.spec.ts', 'inbox-explicit-skip.spec.ts', 'inbox-thread-header.spec.ts', 'inbox-ack.spec.ts', 'inbox-actions.spec.ts', 'inbox-project-menu.spec.ts', 'inbox-row-menu.spec.ts', 'inbox-pins.spec.ts', 'inbox-board.spec.ts', 'inbox-archived.spec.ts', 'inbox-open-stays-put.spec.ts', 'inbox-seen-on-sight.spec.ts', 'inbox-mobile.spec.ts', 'toolstrip-stacking.spec.ts', 'toolstrip-rtl.spec.ts', 'inbox-agents.spec.ts', 'inbox-settings.spec.ts', 'inbox-worst-case.spec.ts', 'inbox-dictation-paste.spec.ts', 'inbox-message-comments.spec.ts', 'inbox-soft-polish.spec.ts', 'inbox-tooltips.spec.ts', 'inbox-tags.spec.ts', 'inbox-refresh.spec.ts', 'inbox-pull-refresh.spec.ts', 'inbox-reply-drafts.spec.ts', 'inbox-picks-composing.spec.ts', 'inbox-shared-events.spec.ts', 'review-diagrams.spec.ts', 'annotate-ai-history.spec.ts', 'image-region-comments.spec.ts', 'surface.spec.ts', 'vscode-guides-sanitize.spec.ts', 'vscode-guides-answer.spec.ts'], outputDir: '../../.local/test-results', fullyParallel: false, workers: 1, From 7a106e24be5c7a9ae9408e73895d7049a9da6b0e Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 26 Oct 2026 17:26:27 -0400 Subject: [PATCH 2/4] Inbox: a picked label's words match the live card Review of #2219: plainLabel no longer drops <...> text (Array, Result), reads a backslash escape as its character (\*, \+), reads a link whose address holds parentheses or whose text holds brackets as its text, an autolink as its address, and a picture with no alt as its file name. cleanInline drops tags only without keepTags, so prompts and bodies read as before. Decision mode's swipe hint says the labels' words (swipeHint); a swipe still commits the label as written. --- packages/inbox/decisions/DecisionCard.tsx | 4 +- packages/inbox/decisions/model.ts | 5 +++ packages/inbox/decisionsModel.test.ts | 8 ++++ packages/inbox/folding.test.ts | 37 ++++++++++++++++++ packages/inbox/folding.ts | 47 +++++++++++++++++------ 5 files changed, 88 insertions(+), 13 deletions(-) diff --git a/packages/inbox/decisions/DecisionCard.tsx b/packages/inbox/decisions/DecisionCard.tsx index f754ab8423..0efe5c6a16 100644 --- a/packages/inbox/decisions/DecisionCard.tsx +++ b/packages/inbox/decisions/DecisionCard.tsx @@ -27,7 +27,7 @@ import { agentName, formatCount, shortTime } from '../format'; import { HostMark, Icon } from '../icons'; import { inboxMessageImageBase, inboxMessageImageUrl } from '../images'; import { Tip } from '../components/Tip'; -import { answerNeedsConfirm, answerWords, choiceAnswer, confirmLabel, otherAnswer, skipAnswer, swipeChoices, textAnswer } from './model'; +import { answerNeedsConfirm, answerWords, choiceAnswer, confirmLabel, otherAnswer, skipAnswer, swipeChoices, swipeHint, textAnswer } from './model'; /** * The contract lines a card shows (the question's additive `stakes`, `why`, @@ -749,7 +749,7 @@ export function DecisionCard(props: DecisionCardProps) { {/* Swipe is touch only, for a two-way choice, and always has its buttons (the choices above). */} {choices2 && props.touch && ( )} diff --git a/packages/inbox/decisions/model.ts b/packages/inbox/decisions/model.ts index 9da0282293..0c499ce93e 100644 --- a/packages/inbox/decisions/model.ts +++ b/packages/inbox/decisions/model.ts @@ -445,6 +445,11 @@ export function swipeChoices(question: InboxQuestion): [string, string] | null { return [question.choices[0]!.label, question.choices[1]!.label]; } +/** The swipe's hint under the card, the labels as their words; a swipe still commits the label as written. */ +export function swipeHint(choices: readonly [string, string]): string { + return `Swipe right for ${plainLabel(choices[0])}, left for ${plainLabel(choices[1])}.`; +} + /** * Whether an answer to a `Reversible: no` question asks once more before it counts (approval * "inbox-decision-mode", agent contract lines; contract-compare.html, pair 2): every answer that chooses diff --git a/packages/inbox/decisionsModel.test.ts b/packages/inbox/decisionsModel.test.ts index a0b996dab1..53cb9332cc 100644 --- a/packages/inbox/decisionsModel.test.ts +++ b/packages/inbox/decisionsModel.test.ts @@ -30,6 +30,7 @@ import { putOff, skipAnswer, swipeChoices, + swipeHint, answerNeedsConfirm, confirmLabel, } from './decisions/model'; @@ -240,6 +241,13 @@ describe('answers', () => { expect(answerWords(answer)).toBe('15% off'); expect(answerWords({ ...answer, selected: ['**20%** for members', '`FLAT10` code'] })).toBe('20% for members, FLAT10 code'); }); + + test("the swipe hint says the labels' words; the swipe commits the label as written", () => { + const linked = { ...A1.question, choices: [{ label: '[Yes](a.html)' }, { label: '`No`' }] } as InboxQuestion; + const choices = swipeChoices(linked)!; + expect(choices).toEqual(['[Yes](a.html)', '`No`']); + expect(swipeHint(choices)).toBe('Swipe right for Yes, left for No.'); + }); }); describe('the contract lines (approval "inbox-decision-mode", agent contract lines)', () => { diff --git a/packages/inbox/folding.test.ts b/packages/inbox/folding.test.ts index 56d5d57185..d094ae3adb 100644 --- a/packages/inbox/folding.test.ts +++ b/packages/inbox/folding.test.ts @@ -151,6 +151,43 @@ test("a picked label's markdown (a link, code, bold) reads as its words on the l expect(plainLabel('snake_case and 2 * 3')).toBe('snake_case and 2 * 3'); }); +test('a picked label keeps <…> text, as the live card shows it; a prompt still drops tags', () => { + expect(plainLabel('Array type')).toBe('Array type'); + expect(plainLabel('Use