Per-option notes in the ask dialog #234

Closed
opened 2026-11-09 10:20:54 +01:00 by omp-deck · 0 comments
Collaborator

Problem

The deck's inline ask dialog (apps/web/src/components/chat/AskDialog.tsx) renders a single
+ Add note expander per question, below the option list and directly under the "Other (type your
own)" textarea. Because of that placement it is impossible to tell whether the note belongs to the
Other option or to the question as a whole.

The feature was requested as parity with the omp CLI, where a note is attached to a specific
option
, not to the question. The CLI's implementation is vendored in this repo at
node_modules/@oh-my-pi/pi-tui/src/overlays/ask-dialog.ts and defines the contract precisely:

  • Exactly one note per question, plus a noteRowKey recording the row it sits on
    (option:<index> or other). Writing a note on another row moves it (#promptForNote, line 1557).
  • clearNoteIfRow (line 452): deselecting a row drops a note sitting on that row.
  • clearNoteUnlessRow (line 456): a single-select pick on a different row drops the note.
  • noteForSubmittedAnswer (line 464): at submit the note is dropped unless its option is actually
    selected — or, for the other row, unless custom text exists. No warning is shown.
  • The result carries note + noteOption ("Label of the option the note is on; unset when it is on
    the custom answer"), and the agent reads … (note on Y: …) / User added note on Y: …
    (node_modules/@oh-my-pi/pi-coding-agent/src/tools/ask.ts:487,526).

The end state: every option row in the deck dialog — including the Other row — carries its own
note control (a pencil icon revealed on hover/focus), the note is attached to one row with the CLI's
exact lifecycle rules, and the agent is told which option the note is on.

Plan

QuestionResult/AskToolDetails, which apps/server/src/bridge/deck-tools.ts already imports from
@oh-my-pi/pi-tui/tools/ask, already declare noteOption — the deck simply never fills it. No
SDK type divergence is needed anywhere in this change.

1. Protocol: carry the option label alongside the note

In packages/protocol/src/index.ts, in ExtUiDialogResponse (the note?: string field at ~line
991), add directly after note:

	/** Label of the option `note` is attached to; unset when the note is on the
	 *  custom ("Other") answer. Mirrors the SDK's `QuestionResult.noteOption`. */
	noteOption?: string;

Nothing else in the protocol package changes. This step is independent of the rest and leaves the
build green on its own.

2. New pure module for the note's row/lifecycle rules

Create apps/web/src/lib/ask-note.ts. The logic lives outside the component so it is unit-testable;
the existing apps/web/src/lib/ask-options.ts + ask-options.test.ts pair is the convention being
followed. Exports, with these exact signatures:

/** Row an ask-dialog note is attached to: an option by its index into the
 *  filtered option list, or the custom-answer row. Mirrors the CLI's
 *  `noteRowKey` in `pi-tui`'s ask dialog. */
export type NoteRowKey = `option:${number}` | "other";

/** Merge-patch that removes a note: spread into an answer update. */
export const CLEAR_NOTE = { note: undefined, noteRowKey: undefined } as const;

export function noteRowForOption(index: number): NoteRowKey;

/** CLI `clearNoteIfRow`: deselecting a row drops a note sitting on it. */
export function clearNoteIfRow(
	current: NoteRowKey | undefined,
	rowKey: NoteRowKey,
): typeof CLEAR_NOTE | Record<string, never>;

/** CLI `clearNoteUnlessRow`: a single-select pick elsewhere drops the note. */
export function clearNoteUnlessRow(
	current: NoteRowKey | undefined,
	rowKey: NoteRowKey,
): typeof CLEAR_NOTE | Record<string, never>;

/** The note that goes out with an answer and the option it is on, mirroring the
 *  CLI's `noteForSubmittedAnswer`: a note whose row is not part of the answer is
 *  dropped, silently, exactly as the CLI drops it. `noteOption` stays unset for a
 *  note on the custom row. */
export function resolveNote(input: {
	note?: string;
	noteRowKey?: NoteRowKey;
	/** Options as rendered, i.e. after `filterOtherOptions`. */
	options: string[];
	/** Labels (and the custom text, for a multi-select) being submitted. */
	selectedValues: string[];
	/** Current text of the custom-answer textarea. */
	customText?: string;
}): { note?: string; noteOption?: string };

resolveNote body: a note that is absent or blank after trim(), or that has no noteRowKey,
yields {}. For "other", yield { note } when customText?.trim() is non-empty, else {}. For
option:<i>, parse the index, look up options[i], and yield { note, noteOption: label } only
when selectedValues.includes(label); otherwise {}. The returned note is the raw (untrimmed)
text, matching today's answer.note?.trim() ? { note: answer.note } : {} in submitAll.

clearNoteIfRow returns CLEAR_NOTE when current === rowKey, else {}. clearNoteUnlessRow
returns CLEAR_NOTE when current !== undefined && current !== rowKey, else {}.

3. Per-option note UI in AskDialog.tsx

All edits in apps/web/src/components/chat/AskDialog.tsx.

State. In interface Answer (line 76), replace the trailing note?: string field and its
comment with:

	// For select questions: free text attached to ONE answer row; never replaces
	// the answer. One note per question, like the CLI.
	note?: string;
	/** Row `note` sits on; undefined exactly when `note` is undefined. */
	noteRowKey?: NoteRowKey;

Import NoteRowKey, CLEAR_NOTE, noteRowForOption, clearNoteIfRow, clearNoteUnlessRow and
resolveNote from @/lib/ask-note, and Pencil from lucide-react (verified present in the
installed lucide-react; it mirrors the CLI's ✎ marker).

Shared resolution helper. Add next to resolveSelectValue (line 46):

/** The note actually submitted for `dialog`, with the option it is on. The single
 *  source of truth for the wire payload and for the review surfaces, so a note the
 *  CLI rules drop is never shown as sent. */
function resolveAnswerNote(
	dialog: OpenFrame,
	answer: Answer | undefined,
): { note?: string; noteOption?: string }

It returns {} unless dialog.kind === "select" and answer exists. Otherwise it computes
options = filterOtherOptions(dialog.options ?? []) and
selectedValues = dialog.multi ? [...(answer.values ?? []), ...(answer.otherText?.trim() ? [answer.otherText.trim()] : [])] : [resolveSelectValue(answer, options)],
then delegates to resolveNote({ note: answer.note, noteRowKey: answer.noteRowKey, options, selectedValues, customText: answer.otherText }).

Submit. In submitAll (line 128) replace
const note = answer.note?.trim() ? { note: answer.note } : {}; with
const note = resolveAnswerNote(dialog, answer);. Both respond(...) calls in the select branch
already spread ...note, so they now emit noteOption too. The non-select branch is untouched.

Review surfaces. The collapsed card (line ~187) and the Summary tab (line ~420) keep their exact
current rendering note: {…} — no option name is added there. Change only the value they read: both
currently test ans?.note?.trim() and print ans.note; they must instead use
resolveAnswerNote(d, ans).note, so a note the submit rules drop is not displayed as part of the
answer. (SummaryTab receives dialogs/answers, so d is in scope at both sites.)

Remove the question-level note. Delete the standalone block at lines 340–345 of QuestionTab:

		{dialog.kind === "select" && (
			<NoteField … />
		)}

Row restructure. Today every row in SelectInput is one <label className="flex cursor-pointer items-center gap-2 rounded border border-line/60 px-2.5 py-1.5 text-sm text-ink hover:border-line">
wrapping the input, the label text and the Recommended badge (multi rows ~482–512, the multi
Other row ~513–521, single rows ~527–553, the single Other row ~555–565). A button nested inside
a <label> would also toggle the input, so introduce a wrapper component used by all four:

interface OptionRowProps {
	/** This row's note key. */
	rowKey: NoteRowKey;
	/** Row label, used in the note control's tooltip and the textarea placeholder. */
	label: string;
	answer: Answer;
	onUpdate: (update: Partial<Answer>) => void;
	/** The row's input + text, rendered inside the clickable `<label>`. */
	children: React.ReactNode;
}

function OptionRow({ rowKey, label, answer, onUpdate, children }: OptionRowProps): JSX.Element

Layout: an outer <div className="group flex flex-col gap-1">; inside it a
<div className="flex items-center gap-1"> holding the existing <label> (unchanged classes plus
flex-1) and, as a sibling, the note button:

<button
	type="button"
	title={open ? `Remove note for ${label}` : `Note for ${label}`}
	aria-label={open ? `Remove note for ${label}` : `Note for ${label}`}
	onClick={() => onUpdate(open ? CLEAR_NOTE : { note: "", noteRowKey: rowKey })}
	className={cn(
		"shrink-0 rounded p-1 transition-opacity hover:text-ink focus-visible:opacity-100",
		open ? "text-accent opacity-100" : "text-ink-3 opacity-0 group-hover:opacity-100",
	)}
>
	<Pencil className="h-3.5 w-3.5" />
</button>

where const open = answer.noteRowKey === rowKey. Clicking a closed row's button moves the note to
that row and starts it empty (the CLI likewise re-keys noteRowKey and prefills only when re-editing
the same row); clicking an open row's button removes the note and collapses the editor. Below the
flex row, when open, render <NoteField label={label} value={answer.note ?? ""} onChange={(note) => onUpdate({ note, noteRowKey: rowKey })} />.

The four call sites pass rowKey={noteRowForOption(idx)} / rowKey="other", and
label={opt} / label="the custom answer" for the Other rows (so the tooltip reads
Note for the custom answer, matching that a note there submits with noteOption unset).

Selection handlers gain the CLI's clearing rules.

  • Multi-select toggleValue(label) → toggleValue(label, index): when removing the label, call
    onUpdate({ values: Array.from(next), ...clearNoteIfRow(answer.noteRowKey, noteRowForOption(index)) });
    when adding, the update is unchanged.
  • Multi-select Other checkbox: when unchecking, onUpdate({ selectedIndex: undefined, ...clearNoteIfRow(answer.noteRowKey, "other") });
    when checking, unchanged.
  • Single-select radio: onUpdate({ selectedIndex: idx, ...clearNoteUnlessRow(answer.noteRowKey, noteRowForOption(idx)) }).
  • Single-select Other radio: onUpdate({ selectedIndex: OTHER_INDEX, ...clearNoteUnlessRow(answer.noteRowKey, "other") }).

Editing the custom textarea does not clear a note on the other row. The CLI clears it only
because its custom answer is entered through a one-shot prompt; here the text is live, and clearing
mid-edit would destroy a note while the user retypes. The outcome still matches: resolveNote drops
an other note whose custom text is empty at submit.

NoteField. Keep the component, drop its expanded state (expansion now lives in
answer.noteRowKey) and give it the signature function NoteField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }).
It renders only the textarea: keep rows={3}, the existing classes, no Enter-to-submit handler, and
focus it on mount via the existing useRef/useEffect pattern. Placeholder becomes
`Note for ${label} — sent along with your answer`. Update its doc comment: the note is attached
to one answer row, rides along with the answer, and is dropped if that row is not part of the
submitted answer.

Validity is untouched: a note never makes an answer valid or invalid.

4. Pass the label through the server bridge

In apps/server/src/bridge/ext-ui-bridge.ts:

  • DeckSelectAnswer (line 58) gains, after note:
    /** Label of the option the note is on; unset when it is on the custom answer. */ noteOption?: string;
  • In deckSelect's response mapper (line ~239), after the existing
    const note = resp.note?.trim() ? resp.note : undefined; add
    const noteOption = note !== undefined && typeof resp.noteOption === "string" ? resp.noteOption : undefined;
    and extend the return to
    { values, ...(note !== undefined ? { note } : {}), ...(noteOption !== undefined ? { noteOption } : {}) }.
    A blank note therefore drops its noteOption as well.
  • formatDetachedAnswer's select case (line ~521) is agent-facing text, so it names the option.
    Replace the single return expression with a const label = response.noteOption === undefined ? "note" : \note on ${response.noteOption}`;andreturn response.note?.trim() ? `${picked}\n ${label}: ${response.note}` : picked;. It stays inline rather than importing formatAskNote, because deck-tools.ts` imports this module and the
    reverse import would be circular.

5. Name the option in the agent-facing answer text

In apps/server/src/bridge/deck-tools.ts:

  • formatAskNote (line 206) becomes export function formatAskNote(note: string, option?: string): string.
    The first line's prefix is note: when option is undefined and ` note on ${option}: `
    otherwise; continuation lines keep the two-space indent unchanged. Update its doc comment to state
    that the option the note is attached to is named, and that it is omitted for a note on the custom
    answer.
  • buildResult (line ~278) adds noteOption: answer.noteOption beside note: answer.note.
  • The single-question details literal (line ~292) adds noteOption: r.noteOption.
  • lineFor (line ~311) takes a fifth parameter noteOption: string | undefined and passes it as
    formatAskNote(note, noteOption); the multi-question call site passes r.noteOption, the
    single-question one details.noteOption.

The result the agent sees for a note on an option therefore reads:

Which approach?: selected: Rewrite the parser
  note on Rewrite the parser: only if it stays under 200 lines

and a note on the custom answer keeps today's note: … form.

apps/web/src/components/tools/Ask.tsx (the transcript card) is deliberately not changed: it
keeps printing note: {sel.note} with no option name.

6. Tests

  • New apps/web/src/lib/ask-note.test.ts (bun:test, mirroring ask-options.test.ts) covering
    resolveNote: a note on a selected option yields { note, noteOption: <label> }; the same note
    with that option absent from selectedValues yields {}; an other-row note yields { note }
    with no noteOption when customText is non-empty and {} when it is blank; a blank or absent
    note yields {}; an out-of-range option:<i> index yields {}. Plus clearNoteIfRow /
    clearNoteUnlessRow returning CLEAR_NOTE only in their respective cases.
  • apps/server/src/bridge/deck-tools.test.ts: extend the formatAskNote describe with a case for
    formatAskNote("be careful", "Option B") === " note on Option B: be careful" and a multi-line
    case asserting the continuation indent is unaffected by the option name.
  • apps/server/src/bridge/ext-ui-bridge.test.ts: add to deckSelect answer parsing that
    { value: "A", note: "ship it", noteOption: "A" } yields { values: ["A"], note: "ship it", noteOption: "A" },
    and that { value: "A", note: " ", noteOption: "A" } yields { values: ["A"] } (blank note drops
    the label too).

Verification

Run from the repo root (/workspace/omp-deck); no env vars or fixtures needed.

  1. bun test apps/web/src/lib/ask-note.test.ts apps/server/src/bridge/deck-tools.test.ts apps/server/src/bridge/ext-ui-bridge.test.ts
    — all pass. (The two server files currently pass with 12 tests; the baseline is green.)
  2. bun run typecheck — clean across @omp-deck/*.
  3. End-to-end, manual, against bun run dev (server on :8787, Vite on :5173): open a session and
    have the agent call ask with a single-select question of 2–3 options.
    • Hovering an option row reveals a pencil at its right edge; clicking it opens a textarea under
      that row, and the row's pencil turns accent-coloured while the others stay hidden until hover.
    • Type because of latency, pick that same option, Submit. The agent's tool result shows
      <question>: selected: <label> followed by note on <label>: because of latency, and the
      collapsed dialog card shows note: because of latency.
    • Repeat, but after typing the note select a different option: the note editor disappears
      (clearNoteUnlessRow) and the submitted result carries no note.
    • On a multi: true question, note an option and then uncheck it: the editor disappears and no
      note is submitted. Note the Other row instead, type custom text, Submit: the result carries
      note: <text> with no option name.
## Problem The deck's inline ask dialog (`apps/web/src/components/chat/AskDialog.tsx`) renders a single `+ Add note` expander per question, below the option list and directly under the "Other (type your own)" textarea. Because of that placement it is impossible to tell whether the note belongs to the `Other` option or to the question as a whole. The feature was requested as parity with the omp CLI, where a note is attached to a **specific option**, not to the question. The CLI's implementation is vendored in this repo at `node_modules/@oh-my-pi/pi-tui/src/overlays/ask-dialog.ts` and defines the contract precisely: - Exactly **one** note per question, plus a `noteRowKey` recording the row it sits on (`option:<index>` or `other`). Writing a note on another row moves it (`#promptForNote`, line 1557). - `clearNoteIfRow` (line 452): deselecting a row drops a note sitting on that row. - `clearNoteUnlessRow` (line 456): a single-select pick on a different row drops the note. - `noteForSubmittedAnswer` (line 464): at submit the note is dropped unless its option is actually selected — or, for the `other` row, unless custom text exists. No warning is shown. - The result carries `note` + `noteOption` ("Label of the option the note is on; unset when it is on the custom answer"), and the agent reads `… (note on Y: …)` / `User added note on Y: …` (`node_modules/@oh-my-pi/pi-coding-agent/src/tools/ask.ts:487,526`). The end state: every option row in the deck dialog — including the `Other` row — carries its own note control (a pencil icon revealed on hover/focus), the note is attached to one row with the CLI's exact lifecycle rules, and the agent is told which option the note is on. ## Plan `QuestionResult`/`AskToolDetails`, which `apps/server/src/bridge/deck-tools.ts` already imports from `@oh-my-pi/pi-tui/tools/ask`, **already declare `noteOption`** — the deck simply never fills it. No SDK type divergence is needed anywhere in this change. ### 1. Protocol: carry the option label alongside the note In `packages/protocol/src/index.ts`, in `ExtUiDialogResponse` (the `note?: string` field at ~line 991), add directly after `note`: ```ts /** Label of the option `note` is attached to; unset when the note is on the * custom ("Other") answer. Mirrors the SDK's `QuestionResult.noteOption`. */ noteOption?: string; ``` Nothing else in the protocol package changes. This step is independent of the rest and leaves the build green on its own. ### 2. New pure module for the note's row/lifecycle rules Create `apps/web/src/lib/ask-note.ts`. The logic lives outside the component so it is unit-testable; the existing `apps/web/src/lib/ask-options.ts` + `ask-options.test.ts` pair is the convention being followed. Exports, with these exact signatures: ```ts /** Row an ask-dialog note is attached to: an option by its index into the * filtered option list, or the custom-answer row. Mirrors the CLI's * `noteRowKey` in `pi-tui`'s ask dialog. */ export type NoteRowKey = `option:${number}` | "other"; /** Merge-patch that removes a note: spread into an answer update. */ export const CLEAR_NOTE = { note: undefined, noteRowKey: undefined } as const; export function noteRowForOption(index: number): NoteRowKey; /** CLI `clearNoteIfRow`: deselecting a row drops a note sitting on it. */ export function clearNoteIfRow( current: NoteRowKey | undefined, rowKey: NoteRowKey, ): typeof CLEAR_NOTE | Record<string, never>; /** CLI `clearNoteUnlessRow`: a single-select pick elsewhere drops the note. */ export function clearNoteUnlessRow( current: NoteRowKey | undefined, rowKey: NoteRowKey, ): typeof CLEAR_NOTE | Record<string, never>; /** The note that goes out with an answer and the option it is on, mirroring the * CLI's `noteForSubmittedAnswer`: a note whose row is not part of the answer is * dropped, silently, exactly as the CLI drops it. `noteOption` stays unset for a * note on the custom row. */ export function resolveNote(input: { note?: string; noteRowKey?: NoteRowKey; /** Options as rendered, i.e. after `filterOtherOptions`. */ options: string[]; /** Labels (and the custom text, for a multi-select) being submitted. */ selectedValues: string[]; /** Current text of the custom-answer textarea. */ customText?: string; }): { note?: string; noteOption?: string }; ``` `resolveNote` body: a note that is absent or blank after `trim()`, or that has no `noteRowKey`, yields `{}`. For `"other"`, yield `{ note }` when `customText?.trim()` is non-empty, else `{}`. For `option:<i>`, parse the index, look up `options[i]`, and yield `{ note, noteOption: label }` only when `selectedValues.includes(label)`; otherwise `{}`. The returned `note` is the raw (untrimmed) text, matching today's `answer.note?.trim() ? { note: answer.note } : {}` in `submitAll`. `clearNoteIfRow` returns `CLEAR_NOTE` when `current === rowKey`, else `{}`. `clearNoteUnlessRow` returns `CLEAR_NOTE` when `current !== undefined && current !== rowKey`, else `{}`. ### 3. Per-option note UI in `AskDialog.tsx` All edits in `apps/web/src/components/chat/AskDialog.tsx`. **State.** In `interface Answer` (line 76), replace the trailing `note?: string` field and its comment with: ```ts // For select questions: free text attached to ONE answer row; never replaces // the answer. One note per question, like the CLI. note?: string; /** Row `note` sits on; undefined exactly when `note` is undefined. */ noteRowKey?: NoteRowKey; ``` Import `NoteRowKey`, `CLEAR_NOTE`, `noteRowForOption`, `clearNoteIfRow`, `clearNoteUnlessRow` and `resolveNote` from `@/lib/ask-note`, and `Pencil` from `lucide-react` (verified present in the installed `lucide-react`; it mirrors the CLI's `✎` marker). **Shared resolution helper.** Add next to `resolveSelectValue` (line 46): ```ts /** The note actually submitted for `dialog`, with the option it is on. The single * source of truth for the wire payload and for the review surfaces, so a note the * CLI rules drop is never shown as sent. */ function resolveAnswerNote( dialog: OpenFrame, answer: Answer | undefined, ): { note?: string; noteOption?: string } ``` It returns `{}` unless `dialog.kind === "select"` and `answer` exists. Otherwise it computes `options = filterOtherOptions(dialog.options ?? [])` and `selectedValues = dialog.multi ? [...(answer.values ?? []), ...(answer.otherText?.trim() ? [answer.otherText.trim()] : [])] : [resolveSelectValue(answer, options)]`, then delegates to `resolveNote({ note: answer.note, noteRowKey: answer.noteRowKey, options, selectedValues, customText: answer.otherText })`. **Submit.** In `submitAll` (line 128) replace `const note = answer.note?.trim() ? { note: answer.note } : {};` with `const note = resolveAnswerNote(dialog, answer);`. Both `respond(...)` calls in the `select` branch already spread `...note`, so they now emit `noteOption` too. The non-select branch is untouched. **Review surfaces.** The collapsed card (line ~187) and the Summary tab (line ~420) keep their exact current rendering `note: {…}` — no option name is added there. Change only the value they read: both currently test `ans?.note?.trim()` and print `ans.note`; they must instead use `resolveAnswerNote(d, ans).note`, so a note the submit rules drop is not displayed as part of the answer. (`SummaryTab` receives `dialogs`/`answers`, so `d` is in scope at both sites.) **Remove the question-level note.** Delete the standalone block at lines 340–345 of `QuestionTab`: ```tsx {dialog.kind === "select" && ( <NoteField … /> )} ``` **Row restructure.** Today every row in `SelectInput` is one `<label className="flex cursor-pointer items-center gap-2 rounded border border-line/60 px-2.5 py-1.5 text-sm text-ink hover:border-line">` wrapping the input, the label text and the `Recommended` badge (multi rows ~482–512, the multi `Other` row ~513–521, single rows ~527–553, the single `Other` row ~555–565). A button nested inside a `<label>` would also toggle the input, so introduce a wrapper component used by all four: ```tsx interface OptionRowProps { /** This row's note key. */ rowKey: NoteRowKey; /** Row label, used in the note control's tooltip and the textarea placeholder. */ label: string; answer: Answer; onUpdate: (update: Partial<Answer>) => void; /** The row's input + text, rendered inside the clickable `<label>`. */ children: React.ReactNode; } function OptionRow({ rowKey, label, answer, onUpdate, children }: OptionRowProps): JSX.Element ``` Layout: an outer `<div className="group flex flex-col gap-1">`; inside it a `<div className="flex items-center gap-1">` holding the existing `<label>` (unchanged classes plus `flex-1`) and, as a sibling, the note button: ```tsx <button type="button" title={open ? `Remove note for ${label}` : `Note for ${label}`} aria-label={open ? `Remove note for ${label}` : `Note for ${label}`} onClick={() => onUpdate(open ? CLEAR_NOTE : { note: "", noteRowKey: rowKey })} className={cn( "shrink-0 rounded p-1 transition-opacity hover:text-ink focus-visible:opacity-100", open ? "text-accent opacity-100" : "text-ink-3 opacity-0 group-hover:opacity-100", )} > <Pencil className="h-3.5 w-3.5" /> </button> ``` where `const open = answer.noteRowKey === rowKey`. Clicking a closed row's button moves the note to that row and starts it empty (the CLI likewise re-keys `noteRowKey` and prefills only when re-editing the same row); clicking an open row's button removes the note and collapses the editor. Below the flex row, when `open`, render `<NoteField label={label} value={answer.note ?? ""} onChange={(note) => onUpdate({ note, noteRowKey: rowKey })} />`. The four call sites pass `rowKey={noteRowForOption(idx)}` / `rowKey="other"`, and `label={opt}` / `label="the custom answer"` for the `Other` rows (so the tooltip reads `Note for the custom answer`, matching that a note there submits with `noteOption` unset). **Selection handlers gain the CLI's clearing rules.** - Multi-select `toggleValue(label)` → `toggleValue(label, index)`: when removing the label, call `onUpdate({ values: Array.from(next), ...clearNoteIfRow(answer.noteRowKey, noteRowForOption(index)) })`; when adding, the update is unchanged. - Multi-select `Other` checkbox: when unchecking, `onUpdate({ selectedIndex: undefined, ...clearNoteIfRow(answer.noteRowKey, "other") })`; when checking, unchanged. - Single-select radio: `onUpdate({ selectedIndex: idx, ...clearNoteUnlessRow(answer.noteRowKey, noteRowForOption(idx)) })`. - Single-select `Other` radio: `onUpdate({ selectedIndex: OTHER_INDEX, ...clearNoteUnlessRow(answer.noteRowKey, "other") })`. Editing the custom textarea does **not** clear a note on the `other` row. The CLI clears it only because its custom answer is entered through a one-shot prompt; here the text is live, and clearing mid-edit would destroy a note while the user retypes. The outcome still matches: `resolveNote` drops an `other` note whose custom text is empty at submit. **`NoteField`.** Keep the component, drop its `expanded` state (expansion now lives in `answer.noteRowKey`) and give it the signature `function NoteField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void })`. It renders only the textarea: keep `rows={3}`, the existing classes, no Enter-to-submit handler, and focus it on mount via the existing `useRef`/`useEffect` pattern. Placeholder becomes `` `Note for ${label} — sent along with your answer` ``. Update its doc comment: the note is attached to one answer row, rides along with the answer, and is dropped if that row is not part of the submitted answer. Validity is untouched: a note never makes an answer valid or invalid. ### 4. Pass the label through the server bridge In `apps/server/src/bridge/ext-ui-bridge.ts`: - `DeckSelectAnswer` (line 58) gains, after `note`: `/** Label of the option the note is on; unset when it is on the custom answer. */ noteOption?: string;` - In `deckSelect`'s response mapper (line ~239), after the existing `const note = resp.note?.trim() ? resp.note : undefined;` add `const noteOption = note !== undefined && typeof resp.noteOption === "string" ? resp.noteOption : undefined;` and extend the return to `{ values, ...(note !== undefined ? { note } : {}), ...(noteOption !== undefined ? { noteOption } : {}) }`. A blank note therefore drops its `noteOption` as well. - `formatDetachedAnswer`'s `select` case (line ~521) is agent-facing text, so it names the option. Replace the single return expression with a `const label = response.noteOption === undefined ? "note" : \`note on ${response.noteOption}\`;` and `return response.note?.trim() ? \`${picked}\n ${label}: ${response.note}\` : picked;`. It stays inline rather than importing `formatAskNote`, because `deck-tools.ts` imports this module and the reverse import would be circular. ### 5. Name the option in the agent-facing answer text In `apps/server/src/bridge/deck-tools.ts`: - `formatAskNote` (line 206) becomes `export function formatAskNote(note: string, option?: string): string`. The first line's prefix is ` note: ` when `option` is undefined and `` ` note on ${option}: ` `` otherwise; continuation lines keep the two-space indent unchanged. Update its doc comment to state that the option the note is attached to is named, and that it is omitted for a note on the custom answer. - `buildResult` (line ~278) adds `noteOption: answer.noteOption` beside `note: answer.note`. - The single-question `details` literal (line ~292) adds `noteOption: r.noteOption`. - `lineFor` (line ~311) takes a fifth parameter `noteOption: string | undefined` and passes it as `formatAskNote(note, noteOption)`; the multi-question call site passes `r.noteOption`, the single-question one `details.noteOption`. The result the agent sees for a note on an option therefore reads: ``` Which approach?: selected: Rewrite the parser note on Rewrite the parser: only if it stays under 200 lines ``` and a note on the custom answer keeps today's ` note: …` form. `apps/web/src/components/tools/Ask.tsx` (the transcript card) is deliberately **not** changed: it keeps printing `note: {sel.note}` with no option name. ### 6. Tests - New `apps/web/src/lib/ask-note.test.ts` (`bun:test`, mirroring `ask-options.test.ts`) covering `resolveNote`: a note on a selected option yields `{ note, noteOption: <label> }`; the same note with that option absent from `selectedValues` yields `{}`; an `other`-row note yields `{ note }` with no `noteOption` when `customText` is non-empty and `{}` when it is blank; a blank or absent note yields `{}`; an out-of-range `option:<i>` index yields `{}`. Plus `clearNoteIfRow` / `clearNoteUnlessRow` returning `CLEAR_NOTE` only in their respective cases. - `apps/server/src/bridge/deck-tools.test.ts`: extend the `formatAskNote` describe with a case for `formatAskNote("be careful", "Option B") === " note on Option B: be careful"` and a multi-line case asserting the continuation indent is unaffected by the option name. - `apps/server/src/bridge/ext-ui-bridge.test.ts`: add to `deckSelect answer parsing` that `{ value: "A", note: "ship it", noteOption: "A" }` yields `{ values: ["A"], note: "ship it", noteOption: "A" }`, and that `{ value: "A", note: " ", noteOption: "A" }` yields `{ values: ["A"] }` (blank note drops the label too). ## Verification Run from the repo root (`/workspace/omp-deck`); no env vars or fixtures needed. 1. `bun test apps/web/src/lib/ask-note.test.ts apps/server/src/bridge/deck-tools.test.ts apps/server/src/bridge/ext-ui-bridge.test.ts` — all pass. (The two server files currently pass with 12 tests; the baseline is green.) 2. `bun run typecheck` — clean across `@omp-deck/*`. 3. End-to-end, manual, against `bun run dev` (server on :8787, Vite on :5173): open a session and have the agent call `ask` with a single-select question of 2–3 options. - Hovering an option row reveals a pencil at its right edge; clicking it opens a textarea under **that** row, and the row's pencil turns accent-coloured while the others stay hidden until hover. - Type `because of latency`, pick that same option, Submit. The agent's tool result shows `<question>: selected: <label>` followed by ` note on <label>: because of latency`, and the collapsed dialog card shows `note: because of latency`. - Repeat, but after typing the note select a *different* option: the note editor disappears (`clearNoteUnlessRow`) and the submitted result carries no note. - On a `multi: true` question, note an option and then uncheck it: the editor disappears and no note is submitted. Note the `Other` row instead, type custom text, Submit: the result carries ` note: <text>` with no option name.
lucas closed this issue 2026-11-09 10:38:34 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#234
No description provided.