Per-option notes in the ask dialog #234
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#234
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The deck's inline ask dialog (
apps/web/src/components/chat/AskDialog.tsx) renders a single+ Add noteexpander per question, below the option list and directly under the "Other (type yourown)" textarea. Because of that placement it is impossible to tell whether the note belongs to the
Otheroption 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.tsand defines the contract precisely:noteRowKeyrecording the row it sits on(
option:<index>orother). 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 actuallyselected — or, for the
otherrow, unless custom text exists. No warning is shown.note+noteOption("Label of the option the note is on; unset when it is onthe 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
Otherrow — carries its ownnote 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, whichapps/server/src/bridge/deck-tools.tsalready imports from@oh-my-pi/pi-tui/tools/ask, already declarenoteOption— the deck simply never fills it. NoSDK type divergence is needed anywhere in this change.
1. Protocol: carry the option label alongside the note
In
packages/protocol/src/index.ts, inExtUiDialogResponse(thenote?: stringfield at ~line991), add directly after
note: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.tspair is the convention beingfollowed. Exports, with these exact signatures:
resolveNotebody: a note that is absent or blank aftertrim(), or that has nonoteRowKey,yields
{}. For"other", yield{ note }whencustomText?.trim()is non-empty, else{}. Foroption:<i>, parse the index, look upoptions[i], and yield{ note, noteOption: label }onlywhen
selectedValues.includes(label); otherwise{}. The returnednoteis the raw (untrimmed)text, matching today's
answer.note?.trim() ? { note: answer.note } : {}insubmitAll.clearNoteIfRowreturnsCLEAR_NOTEwhencurrent === rowKey, else{}.clearNoteUnlessRowreturns
CLEAR_NOTEwhencurrent !== undefined && current !== rowKey, else{}.3. Per-option note UI in
AskDialog.tsxAll edits in
apps/web/src/components/chat/AskDialog.tsx.State. In
interface Answer(line 76), replace the trailingnote?: stringfield and itscomment with:
Import
NoteRowKey,CLEAR_NOTE,noteRowForOption,clearNoteIfRow,clearNoteUnlessRowandresolveNotefrom@/lib/ask-note, andPencilfromlucide-react(verified present in theinstalled
lucide-react; it mirrors the CLI's✎marker).Shared resolution helper. Add next to
resolveSelectValue(line 46):It returns
{}unlessdialog.kind === "select"andanswerexists. Otherwise it computesoptions = filterOtherOptions(dialog.options ?? [])andselectedValues = 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) replaceconst note = answer.note?.trim() ? { note: answer.note } : {};withconst note = resolveAnswerNote(dialog, answer);. Bothrespond(...)calls in theselectbranchalready spread
...note, so they now emitnoteOptiontoo. 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: bothcurrently test
ans?.note?.trim()and printans.note; they must instead useresolveAnswerNote(d, ans).note, so a note the submit rules drop is not displayed as part of theanswer. (
SummaryTabreceivesdialogs/answers, sodis in scope at both sites.)Remove the question-level note. Delete the standalone block at lines 340–345 of
QuestionTab:Row restructure. Today every row in
SelectInputis 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
Recommendedbadge (multi rows ~482–512, the multiOtherrow ~513–521, single rows ~527–553, the singleOtherrow ~555–565). A button nested insidea
<label>would also toggle the input, so introduce a wrapper component used by all four: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 plusflex-1) and, as a sibling, the note button:where
const open = answer.noteRowKey === rowKey. Clicking a closed row's button moves the note tothat row and starts it empty (the CLI likewise re-keys
noteRowKeyand prefills only when re-editingthe 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", andlabel={opt}/label="the custom answer"for theOtherrows (so the tooltip readsNote for the custom answer, matching that a note there submits withnoteOptionunset).Selection handlers gain the CLI's clearing rules.
toggleValue(label)→toggleValue(label, index): when removing the label, callonUpdate({ values: Array.from(next), ...clearNoteIfRow(answer.noteRowKey, noteRowForOption(index)) });when adding, the update is unchanged.
Othercheckbox: when unchecking,onUpdate({ selectedIndex: undefined, ...clearNoteIfRow(answer.noteRowKey, "other") });when checking, unchanged.
onUpdate({ selectedIndex: idx, ...clearNoteUnlessRow(answer.noteRowKey, noteRowForOption(idx)) }).Otherradio:onUpdate({ selectedIndex: OTHER_INDEX, ...clearNoteUnlessRow(answer.noteRowKey, "other") }).Editing the custom textarea does not clear a note on the
otherrow. The CLI clears it onlybecause 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:
resolveNotedropsan
othernote whose custom text is empty at submit.NoteField. Keep the component, drop itsexpandedstate (expansion now lives inanswer.noteRowKey) and give it the signaturefunction 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, andfocus it on mount via the existing
useRef/useEffectpattern. Placeholder becomes`Note for ${label} — sent along with your answer`. Update its doc comment: the note is attachedto 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, afternote:/** Label of the option the note is on; unset when it is on the custom answer. */ noteOption?: string;deckSelect's response mapper (line ~239), after the existingconst note = resp.note?.trim() ? resp.note : undefined;addconst 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
noteOptionas well.formatDetachedAnswer'sselectcase (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 importingformatAskNote, becausedeck-tools.ts` imports this module and thereverse 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) becomesexport function formatAskNote(note: string, option?: string): string.The first line's prefix is
note:whenoptionis 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) addsnoteOption: answer.noteOptionbesidenote: answer.note.detailsliteral (line ~292) addsnoteOption: r.noteOption.lineFor(line ~311) takes a fifth parameternoteOption: string | undefinedand passes it asformatAskNote(note, noteOption); the multi-question call site passesr.noteOption, thesingle-question one
details.noteOption.The result the agent sees for a note on an option therefore reads:
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: itkeeps printing
note: {sel.note}with no option name.6. Tests
apps/web/src/lib/ask-note.test.ts(bun:test, mirroringask-options.test.ts) coveringresolveNote: a note on a selected option yields{ note, noteOption: <label> }; the same notewith that option absent from
selectedValuesyields{}; another-row note yields{ note }with no
noteOptionwhencustomTextis non-empty and{}when it is blank; a blank or absentnote yields
{}; an out-of-rangeoption:<i>index yields{}. PlusclearNoteIfRow/clearNoteUnlessRowreturningCLEAR_NOTEonly in their respective cases.apps/server/src/bridge/deck-tools.test.ts: extend theformatAskNotedescribe with a case forformatAskNote("be careful", "Option B") === " note on Option B: be careful"and a multi-linecase asserting the continuation indent is unaffected by the option name.
apps/server/src/bridge/ext-ui-bridge.test.ts: add todeckSelect answer parsingthat{ 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 dropsthe label too).
Verification
Run from the repo root (
/workspace/omp-deck); no env vars or fixtures needed.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.)
bun run typecheck— clean across@omp-deck/*.bun run dev(server on :8787, Vite on :5173): open a session andhave the agent call
askwith a single-select question of 2–3 options.that row, and the row's pencil turns accent-coloured while the others stay hidden until hover.
because of latency, pick that same option, Submit. The agent's tool result shows<question>: selected: <label>followed bynote on <label>: because of latency, and thecollapsed dialog card shows
note: because of latency.(
clearNoteUnlessRow) and the submitted result carries no note.multi: truequestion, note an option and then uncheck it: the editor disappears and nonote is submitted. Note the
Otherrow instead, type custom text, Submit: the result carriesnote: <text>with no option name.