Skip to content

fix(web): persist pending plan input drafts and stabilize preset answers#1137

Open
jamesx0416 wants to merge 2 commits intopingdotgg:mainfrom
jamesx0416:t3code/fix-plan-input-cursor-draft
Open

fix(web): persist pending plan input drafts and stabilize preset answers#1137
jamesx0416 wants to merge 2 commits intopingdotgg:mainfrom
jamesx0416:t3code/fix-plan-input-cursor-draft

Conversation

@jamesx0416
Copy link
Contributor

@jamesx0416 jamesx0416 commented Mar 16, 2026

Closes #375
Closes #918
Closes #528
Supersedes #921

What Changed

  • persist pending plan-mode user-input drafts in a dedicated thread/request-scoped store so they survive thread switches
  • make pending answers explicitly track whether the active source is a preset option or custom text
  • make preset selection authoritative so stale custom editor state cannot override it during submit
  • remove the timeout-based preset auto-advance/submit race and cover the pending-input flows with browser and unit tests

Why

Plan-mode pending user input had three user-facing correctness bugs in the web client:

  • while answering a plan-mode question, moving the caret away from the end of the custom input would snap it back to the end, which made mid-text edits effectively impossible
  • if you switched to another thread and then came back, the in-progress pending answer could disappear because that draft state was local to ChatView
  • if you typed custom text and then clicked a preset option, the flow could race and submit the stale custom text instead of the clicked preset

This keeps the fix scoped to the pending-input flow and makes answer transitions deterministic.

UI Changes

Interaction fix in the pending-input composer/preset flow.

  • pending custom drafts now survive thread navigation
  • caret edits in the custom answer field no longer snap to the end
  • clicking a preset after typing custom text now submits the preset instead of the stale custom answer

Videos

Before:
https://drive.google.com/file/d/14BZBw1eOdVpHUkeXesZhcPjferbygFmV/view?usp=sharing

After:
https://drive.google.com/file/d/12kl7Yd2Y5cy4UMer7jSU74Uc-CjpLcYS/view?usp=sharing

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

Verification

  • bun fmt
  • bun lint
  • bun typecheck
  • bun run --cwd apps/web test -- src/pendingUserInput.test.ts
  • bun run --cwd apps/web test:browser -- src/components/ChatView.browser.tsx

Note

Persist pending user-input drafts per thread and stabilize preset answer selection

  • Adds pendingUserInputDraftStore.ts, a persisted Zustand store that saves pending user-input answers and question indices per thread to localStorage, surviving navigation and page reloads.
  • Introduces explicit answerSource tracking ('custom' vs 'option') in pendingUserInput.ts so preset selections are not overwritten by stale custom text and vice versa.
  • Moves auto-advance/submit logic from ComposerPendingUserInputCard's internal timer into ChatView, triggered via updated onSelectOption options; removes the onAdvance prop from ComposerPendingUserInputPanel.tsx.
  • Prunes drafts for inactive pending requests whenever thread content changes to avoid stale state accumulation.
  • Behavioral Change: selecting a preset option now immediately clears custom answer text and can auto-submit when on the last question, replacing the previous timer-based advance.

Macroscope summarized f70ba78.

Summary by CodeRabbit

  • New Features
    • Pending user-input answers now save as per-conversation drafts that persist across sessions and thread navigation.
    • Preset (option) vs custom answers are tracked separately so selecting presets won’t be lost to stale custom text.
    • Option selection can auto-advance or auto-submit in multi-question prompts.
  • Bug Fixes
    • Preserves in-progress edits and caret position when navigating between threads.

- Move pending user-input draft state into a per-thread draft store
- Track explicit answer source (`option` vs `custom`) to avoid stale custom text overriding preset picks
- Preserve pending input edits/cursor behavior across caret moves and thread navigation
- Update pending-input panel flow to auto-advance/submit on option select and add coverage
@github-actions github-actions bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 16, 2026
@coderabbitai
Copy link

coderabbitai bot commented Mar 16, 2026

📝 Walkthrough

Walkthrough

Per-thread persistent drafts for pending user input were added via a new Zustand store; components migrated from local state to this draft store, pending-answer source tracking (option vs custom) was introduced, Composer panel selection API changed to include advance/submit flags, and tests were extended for multi-question and navigation flows.

Changes

Cohort / File(s) Summary
Draft Store Infrastructure
apps/web/src/pendingUserInputDraftStore.ts
New persistent, debounced Zustand store for per-thread pending-user-input drafts, with APIs to set question index, set answers, clear inactive requests, and a thread-specific selector.
Pending Input Domain Logic
apps/web/src/pendingUserInput.ts, apps/web/src/pendingUserInput.test.ts
Added PendingUserInputAnswerSource and answerSource on draft answers; implemented setPendingUserInputSelectedOption, updated custom-answer handling and progress derivation to respect explicit answer source; tests added/updated for option vs custom semantics.
ChatView Migration
apps/web/src/components/ChatView.tsx, apps/web/src/components/ChatView.browser.tsx
Replaced component-local pending-input state with draft-store hooks (usePendingUserInputDraftStore, usePendingUserInputThreadDraft), added lifecycle cleanup (clearInactiveRequests), refs to coordinate write sources, and updated handlers to persist drafts. Browser tests adjusted/expanded to exercise draft persistence, caret/selection behavior, and multi-question flows.
Composer Panel API Changes
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
Reworked onSelectOption signature to accept options?: { advanceToNextQuestion?: boolean; submitIfComplete?: boolean }, removed onAdvance prop and internal auto-advance timer; selection now signals auto-advance/submit through options flags.
Test Utilities & Fixtures
apps/web/src/components/ChatView.browser.tsx (test helpers), apps/web/src/pendingUserInput.test.ts
Added test fixtures and utilities (composer interaction helpers, snapshot creators) and multiple tests validating preservation of pending drafts, preset vs custom handling, cursor/edit behavior, and multi-question flows.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Composer
    participant ChatView
    participant DraftStore
    participant Storage

    User->>Composer: type or select answer
    Composer->>ChatView: onChange/onSelect(questionId, label, {advance/submit?})
    ChatView->>DraftStore: setAnswer(threadId, requestId, questionId, draft)
    DraftStore->>Storage: persist (debounced)
    Storage-->>DraftStore: persisted

    Note over User,ChatView: navigate away and return

    User->>ChatView: switch to thread
    ChatView->>DraftStore: usePendingUserInputThreadDraft(threadId)
    DraftStore-->>ChatView: restored answers & questionIndex
    ChatView->>Composer: supply restored draft
    Composer-->>User: render preserved input
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I nibbled drafts into cozy store,

No lost replies when hopping door to door.
Options and text kept tidy and clear,
Cursor freed to leap back, hop and steer.
Cheers — your answers wait, safe and near.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: persisting pending plan input drafts and stabilizing preset answers, which directly corresponds to the core functionality added.
Description check ✅ Passed The description fully addresses the template with clear 'What Changed' and 'Why' sections, includes UI changes, videos, and verification steps. All required template sections are present and substantive.
Linked Issues check ✅ Passed All code changes directly address the three linked issues: #375 (cursor movement fix via draft store preventing snap-back), #918 (draft persistence via Zustand store surviving thread switches), and #528 (editable cursor behavior enabled by proper state management).
Out of Scope Changes check ✅ Passed All changes are scoped to pending user input flows: draft store, pending answer source tracking, auto-advance logic refactoring, and related test additions. No unrelated modifications detected outside the stated objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can suggest fixes for GitHub Check annotations.

Configure the reviews.tools.github-checks setting to adjust the time to wait for GitHub Checks to complete.

@jamesx0416
Copy link
Contributor Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 16, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

1 similar comment
@coderabbitai
Copy link

coderabbitai bot commented Mar 16, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/components/ChatView.tsx (1)

3485-3491: ⚠️ Potential issue | 🟠 Major

Pass the user-input responding state into the pending-input panel.

This still passes respondingRequestIds from approval responses. While thread.user-input.respond is in flight, the preset buttons and digit shortcuts remain enabled, so the new auto-submit path can dispatch duplicate user-input responses.

💡 Suggested fix
                     <ComposerPendingUserInputPanel
                       pendingUserInputs={pendingUserInputs}
-                      respondingRequestIds={respondingRequestIds}
+                      respondingRequestIds={respondingUserInputRequestIds}
                       answers={activePendingDraftAnswers}
                       questionIndex={activePendingQuestionIndex}
                       onSelectOption={onSelectActivePendingUserInputOption}
                     />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/ChatView.tsx` around lines 3485 - 3491, Replace the
generic approval-response `respondingRequestIds` prop with the user-input
responding state so the pending-input panel disables presets while a
`thread.user-input.respond` is in flight: locate the
ComposerPendingUserInputPanel usage and either pass an existing variable that
tracks user-input responses (e.g., userInputRespondingRequestIds or
threadUserInputRespondingIds) or add a selector/prop that returns the request
IDs for `thread.user-input.respond`, and pass that prop (name it clearly like
userInputRespondingRequestIds) into ComposerPendingUserInputPanel instead of the
approval `respondingRequestIds` so the panel can correctly disable
buttons/shortcuts during user-input respond.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/components/ChatView.browser.tsx`:
- Around line 406-419: The secondary thread fixture copies
baseSnapshot.threads[0]!.session so its session.threadId still equals THREAD_ID;
update the copied thread to have a session whose threadId is SECOND_THREAD_ID
(and if the session object contains an id that encodes the thread, update that
too) so the thread’s session uniquely references SECOND_THREAD_ID instead of
THREAD_ID (locate the object with SECOND_THREAD_ID in the diff and
replace/override its session.threadId accordingly).

In `@apps/web/src/components/ChatView.tsx`:
- Around line 666-668: The effect currently calls
clearInactivePendingUserInputDraftRequests(threadId,
activePendingUserInputRequestIds) immediately, which can delete restored drafts
before pending requests hydrate; change the effect to guard so it only runs
after pendingUserInputs have been hydrated (e.g., add pendingUserInputs to the
dependency array and check that pendingUserInputs is not undefined/null and/or
that pendingUserInputs.length > 0 OR activePendingUserInputRequestIds.length >
0) before invoking clearInactivePendingUserInputDraftRequests; this ensures you
only prune drafts once the thread's pending inputs have loaded.

In `@apps/web/src/pendingUserInput.ts`:
- Around line 56-68: Normalize the stored customAnswer to collapse
whitespace-only drafts: keep calling normalizeDraftAnswer(customAnswer) into
normalizedCustomAnswer, then compute a storedCustomAnswer variable that is
customAnswer when normalizedCustomAnswer is truthy, but if
normalizedCustomAnswer is falsy and customAnswer is only whitespace
(customAnswer?.trim() === ""), set storedCustomAnswer to "" (otherwise keep the
original customAnswer); return storedCustomAnswer instead of the raw
customAnswer and leave the existing logic around selectedOptionLabel and
answerSource (references: normalizeDraftAnswer, customAnswer,
selectedOptionLabel, normalizedCustomAnswer).

---

Outside diff comments:
In `@apps/web/src/components/ChatView.tsx`:
- Around line 3485-3491: Replace the generic approval-response
`respondingRequestIds` prop with the user-input responding state so the
pending-input panel disables presets while a `thread.user-input.respond` is in
flight: locate the ComposerPendingUserInputPanel usage and either pass an
existing variable that tracks user-input responses (e.g.,
userInputRespondingRequestIds or threadUserInputRespondingIds) or add a
selector/prop that returns the request IDs for `thread.user-input.respond`, and
pass that prop (name it clearly like userInputRespondingRequestIds) into
ComposerPendingUserInputPanel instead of the approval `respondingRequestIds` so
the panel can correctly disable buttons/shortcuts during user-input respond.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1358f907-5b9b-4fab-b8ee-b7a93d0a5f6b

📥 Commits

Reviewing files that changed from the base of the PR and between e6d9a27 and 7bf2a1d.

📒 Files selected for processing (6)
  • apps/web/src/components/ChatView.browser.tsx
  • apps/web/src/components/ChatView.tsx
  • apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
  • apps/web/src/pendingUserInput.test.ts
  • apps/web/src/pendingUserInput.ts
  • apps/web/src/pendingUserInputDraftStore.ts

- Keep secondary snapshot thread sessions aligned with their thread IDs
- Only clear inactive pending-input drafts when an active thread exists
- Pass user-input responding request IDs to the composer pending-input panel
@jamesx0416
Copy link
Contributor Author

jamesx0416 commented Mar 18, 2026

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 18, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/web/src/components/ChatView.tsx (1)

2648-2648: Extract the suppression TTL into a named constant.

Using a named constant improves readability and makes timing adjustments safer.

💡 Suggested refactor
+const PENDING_CUSTOM_RESTORE_SUPPRESSION_MS = 500;
-        expiresAtMs: performance.now() + 500,
+        expiresAtMs: performance.now() + PENDING_CUSTOM_RESTORE_SUPPRESSION_MS,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/ChatView.tsx` at line 2648, Replace the magic number
500 used for suppression TTL in the expiresAtMs assignment with a named constant
(e.g., SUPPRESSION_TTL_MS) declared near the top of the ChatView component or
module scope; update the expiresAtMs: performance.now() + 500 expression in the
code that sets suppression (the occurrence around expiresAtMs in ChatView.tsx)
to use SUPPRESSION_TTL_MS, and choose a clear name and location so future
changes to the suppression duration are centralized and readable.
apps/web/src/components/ChatView.browser.tsx (1)

686-700: Use the latest matching thread.user-input.respond request in the helper.

This helper currently picks the first matching request, which can become stale if a test emits multiple responses.

💡 Suggested change
-      const request = wsRequests.find((wsRequest) => {
+      const request = [...wsRequests].reverse().find((wsRequest) => {
         if (wsRequest._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand) {
           return false;
         }
         const command =
           "command" in wsRequest && wsRequest.command && typeof wsRequest.command === "object"
             ? (wsRequest.command as Record<string, unknown>)
             : null;
         return command?.type === "thread.user-input.respond";
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/ChatView.browser.tsx` around lines 686 - 700, The
helper waitForPendingUserInputResponse currently finds the first matching
wsRequest and can return stale data; instead locate the latest matching
"thread.user-input.respond" dispatchCommand by scanning wsRequests for all
entries where wsRequest._tag === ORCHESTRATION_WS_METHODS.dispatchCommand and
the command object has type === "thread.user-input.respond" and pick the last
one (e.g., findLast or reverse iterate) before asserting and extracting answers;
update the request lookup in waitForPendingUserInputResponse to use that latest
match so tests that emit multiple responses use the most recent message.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/web/src/components/ChatView.browser.tsx`:
- Around line 686-700: The helper waitForPendingUserInputResponse currently
finds the first matching wsRequest and can return stale data; instead locate the
latest matching "thread.user-input.respond" dispatchCommand by scanning
wsRequests for all entries where wsRequest._tag ===
ORCHESTRATION_WS_METHODS.dispatchCommand and the command object has type ===
"thread.user-input.respond" and pick the last one (e.g., findLast or reverse
iterate) before asserting and extracting answers; update the request lookup in
waitForPendingUserInputResponse to use that latest match so tests that emit
multiple responses use the most recent message.

In `@apps/web/src/components/ChatView.tsx`:
- Line 2648: Replace the magic number 500 used for suppression TTL in the
expiresAtMs assignment with a named constant (e.g., SUPPRESSION_TTL_MS) declared
near the top of the ChatView component or module scope; update the expiresAtMs:
performance.now() + 500 expression in the code that sets suppression (the
occurrence around expiresAtMs in ChatView.tsx) to use SUPPRESSION_TTL_MS, and
choose a clear name and location so future changes to the suppression duration
are centralized and readable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a70128a3-606a-47a9-98aa-e05c679d260b

📥 Commits

Reviewing files that changed from the base of the PR and between 7bf2a1d and f70ba78.

📒 Files selected for processing (2)
  • apps/web/src/components/ChatView.browser.tsx
  • apps/web/src/components/ChatView.tsx

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

1 participant