diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4598f76af..88ceaf3ee 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/core/inbox-folding.ts b/packages/core/inbox-folding.ts index 25228a584..3cfedf395 100644 --- a/packages/core/inbox-folding.ts +++ b/packages/core/inbox-folding.ts @@ -10,7 +10,7 @@ import { isInboxActionReport, isInboxNote, type InboxMessageWire, type InboxQues import { QUESTION_ANSWERS_HEADING, type QuestionAnswer } from './question-block'; import { INBOX_STILL_OPEN_LINE, sliceText } from './inbox-questions'; import { withoutInboxSkillsBlock } from './skill-reference-block'; -import { stripInlineMarkup } from './inline-markdown'; +import { INLINE_DESTINATION, stripInlineMarkup } from './inline-markdown'; /** * Where "new since you looked" starts: the seq of the person's last look at @@ -110,10 +110,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(', '); @@ -233,15 +237,24 @@ export function snippetText(snippet: FoldSnippet): string { * tags out, and paired emphasis marks (`**x**`, `__x__`, `~~x~~`, `*x*`, * `_x_`) dropped only where they open and close at word boundaries, so * `snake_case` and `2 * 3` keep their characters. Inline code keeps its text - * (only the backticks go). + * (only the backticks go). A backslash escape (`\*`, `\+`) reads as its + * character, as the inline renderer draws it. A link's destination may hold + * one level of parentheses (`…/Foo_(bar)`). `keepTags` keeps `<…>` text (a + * choice label: the card draws `Array` literally). */ -export function cleanInline(text: string): string { +export function cleanInline(text: string, options: { keepTags?: boolean } = {}): string { const code: string[] = []; let out = text.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/g, (_m, _ticks: string, inner: string) => { code.push(inner.trim()); return `\u0000${code.length - 1}\u0000`; }); - out = stripInlineMarkup(out); + // Escaped characters are set aside so no rule below reads them as marks. + const escaped: string[] = []; + out = out.replace(ESCAPED, (_m, ch: string) => { + escaped.push(ch); + return `\u0001${escaped.length - 1}\u0001`; + }); + out = stripInlineMarkup(out, { keepTags: options.keepTags }); // A closing mark ends a word: whitespace, the end, or punctuation that does // not run into another word (`__init__.py` is a name, not emphasis). const close = `(?=$|\\s|[,;:!?)\\]"'’]|\\.(?!\\w))`; @@ -249,7 +262,35 @@ export function cleanInline(text: string): string { const pair = new RegExp(`(^|[^\\w*~])${mark}(?=\\S)([\\s\\S]*?\\S)${mark}${close}`, 'g'); out = out.replace(pair, '$1$2'); } - return out.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => code[Number(i)] ?? ''); + return out.replace(/\u0001(\d+)\u0001/g, (_m, i: string) => escaped[Number(i)] ?? '').replace(/\u0000(\d+)\u0000/g, (_m, i: string) => code[Number(i)] ?? ''); +} + +/** The characters a backslash escapes, as the inline renderer reads them (ui's InlineMarkdown). */ +const ESCAPED = /\\([\\*_`[\]~!.()\-#>+|{}&$])/g; + +/** + * 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. The words are the ones the live card + * shows: `<…>` text stays (`Array`), an autolink reads as its + * address, an escape as its character. A label that is only an image reads + * as its alt text, else its file name, so the pick never shows as nothing. + */ +export function plainLabel(label: string): string { + const plain = (text: string) => + cleanInline(text.replace(/<(https?:\/\/[^<>]+)>/g, '$1').replace(/<([^<@>\s]+@[^<>\s]+)>/g, '$1'), { keepTags: true }) + .replace(/\s+/g, ' ') + .trim(); + // Bounded like inline-markdown.ts: each pattern stops at the next opener. + const image = /!\[([^\][]*)\]\(\s*]|\([^\s()<>]*\))*)/g; + return ( + plain(label) || + plain(label.replace(new RegExp(String.raw`!\[([^\][]*)\]` + INLINE_DESTINATION, 'g'), '$1')) || + [...label.matchAll(image)].map((m) => m[2]!.split(/[\\/]/).pop()!.replace(/[?#].*$/, '')).filter(Boolean).join(', ') || + label.trim() + ); } /** diff --git a/packages/core/inline-markdown.test.ts b/packages/core/inline-markdown.test.ts index b93f0dc09..e0adfa970 100644 --- a/packages/core/inline-markdown.test.ts +++ b/packages/core/inline-markdown.test.ts @@ -8,7 +8,7 @@ * tags takes most of a second to read (every card render and every send). */ import { expect, test } from "bun:test"; -import { cleanInline } from "./inbox-folding"; +import { cleanInline, plainLabel } from "./inbox-folding"; import { stripInlineMarkup } from "./inline-markdown"; test("links, wiki links, images and tags out, their words kept", () => { @@ -21,10 +21,19 @@ test("links, wiki links, images and tags out, their words kept", () => { expect(stripInlineMarkup("see ![x](a_(1).png)here")).toBe("see here"); }); -test("a 20,000-character run of brackets or tags is read in linear time, here and in cleanInline", () => { - for (const run of ["[", "[[", "![", " { + expect(stripInlineMarkup("[a [nested] label](x.html)")).toBe("a [nested] label"); + expect(stripInlineMarkup("[a [b [c] d] e](x.html)")).toBe("[a [b [c] d] e](x.html)"); +}); + +test("tags stay with keepTags (a choice label reads `Array`)", () => { + expect(stripInlineMarkup("[Array](t.html) type", { keepTags: true })).toBe("Array type"); +}); + +test("a 20,000-character run of brackets or tags is read in linear time, here, in cleanInline and in plainLabel", () => { + for (const run of ["[", "[[", "![", "`). Emphasis and code marks are left to + * the caller (the Inbox's `cleanInline` protects code spans and escapes around + * this; the question parser's `normalizeQuestionText` drops the marks). * * Imports nothing, so core/question-block and core/inbox-folding can both use * it with no cycle (Pi vendors it beside question-block: apps/pi-extension/vendor.sh). * * Every pattern stops at the next opener (`[`, `<`; a link's destination at - * a `(` it does not close), so a label of thousands of `[`, `[[`, `![` or - * ` label ?? target) - .replace(/<\/?[A-Za-z][^<>]*>/g, ''); + .replace(/\[\[([^\][|]*)(?:\|([^\][]*))?\]\]/g, (_m, target: string, label?: string) => label ?? target); + return options.keepTags ? out : out.replace(/<\/?[A-Za-z][^<>]*>/g, ''); } diff --git a/packages/inbox/choiceLabels.test.tsx b/packages/inbox/choiceLabels.test.tsx new file mode 100644 index 000000000..50049d266 --- /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 276b6a661..3728f27e0 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/DecisionCard.tsx b/packages/inbox/decisions/DecisionCard.tsx index 011506217..38b8a3bd3 100644 --- a/packages/inbox/decisions/DecisionCard.tsx +++ b/packages/inbox/decisions/DecisionCard.tsx @@ -26,7 +26,7 @@ import { HostMark, Icon } from '../icons'; import { inboxMessageImageUrl } from '../images'; import { Tip } from '../components/Tip'; import { ContextMarkdown } from '../components/ContextMarkdown'; -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`, @@ -724,7 +724,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 2b44ffd75..a1f9f1703 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'; @@ -445,7 +446,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'); @@ -462,6 +464,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 6e378a3f6..ef7c94267 100644 --- a/packages/inbox/decisionsModel.test.ts +++ b/packages/inbox/decisionsModel.test.ts @@ -30,6 +30,7 @@ import { putOff, skipAnswer, swipeChoices, + swipeHint, answerNeedsConfirm, confirmLabel, sessionChips, @@ -241,6 +242,21 @@ 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'); + }); + + 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 806facd03..7707aa5a4 100644 --- a/packages/inbox/folding.test.ts +++ b/packages/inbox/folding.test.ts @@ -6,7 +6,8 @@ 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 { parseInboxQuestionBlocks } from '@plannotator/core/inbox-questions'; +import { SNIPPET_PROMPT_MAX, answerWords, 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 +127,80 @@ 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 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