Skip to content

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

Open
jamesx0416 wants to merge 1 commit 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 1 commit 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 plan input drafts per thread and fix preset answer priority

  • Adds a new Zustand store (pendingUserInputDraftStore.ts) that persists per-thread, per-request draft answers and question index to localStorage, pruning inactive requests automatically.
  • Introduces an explicit answerSource field ('option' or 'custom') to PendingUserInputDraftAnswer so a selected preset stays active even when stale custom text exists in the composer.
  • Updates ChatView.tsx to read/write draft state from the store, sync the composer with the active question's custom answer, and avoid resetting the composer when no pending question is active.
  • Selecting a preset option in ComposerPendingUserInputPanel.tsx now immediately advances to the next question or submits when complete, removing the previous timeout-based auto-advance.
  • Behavioral Change: stale custom text in the composer no longer overrides a selected preset; clearing custom text restores the selected preset as the active answer.

Macroscope summarized 7bf2a1d.

Summary by CodeRabbit

Release Notes

  • New Features
    • Pending user input answers are now automatically saved as drafts and persist across conversations
    • Improved handling of preset vs. custom answer selections with better state preservation
    • Auto-advance functionality when selecting options in multi-question flows

- 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

A new persistent draft store manages pending user input answers per thread, replacing local component state. This enables answer preservation across thread navigation and improves user experience during multi-question workflows. Component signatures are updated to reflect new auto-advance options, and extensive test coverage validates the draft preservation and cursor interaction flows.

Changes

Cohort / File(s) Summary
Draft Store Infrastructure
apps/web/src/pendingUserInputDraftStore.ts
New Zustand-based persistent store for managing pending user input drafts per thread, with debounced localStorage sync, per-thread question indices, and inactive request cleanup logic.
Answer Source and Draft Handling
apps/web/src/pendingUserInput.ts, apps/web/src/pendingUserInput.test.ts
Added PendingUserInputAnswerSource type to distinguish "option" vs "custom" answers. New setPendingUserInputSelectedOption function and updated resolution/progress logic to respect explicit answer sources. Tests validate source tracking and answer persistence.
ChatView Component Refactoring
apps/web/src/components/ChatView.tsx
Migrated from local state to draft store for managing pending user input answers and question indices. Added effects for draft lifecycle management, refs to coordinate write sources, and updated handlers to persist edits through the draft store instead of component state.
Composer Panel Signature Updates
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
Updated onSelectOption prop to accept optional advanceToNextQuestion and submitIfComplete flags; removed onAdvance prop and internal auto-advance timer logic. Auto-advance behavior now driven by options parameter.
Test Coverage for Pending Input Workflows
apps/web/src/components/ChatView.browser.tsx
Comprehensive test suite covering pending answer preservation across thread navigation, cursor positioning, preset vs. custom answer handling, and multi-question prompt scenarios.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant ChatView
    participant ComposerPanel
    participant DraftStore
    participant Storage["localStorage"]
    
    User->>ComposerPanel: Edit answer or select option
    ComposerPanel->>ChatView: onSelectOption(questionId, label, options)
    ChatView->>DraftStore: setPendingUserInputDraftAnswer(threadId, requestId, questionId, answer)
    DraftStore->>Storage: persist (debounced)
    Storage-->>DraftStore: sync complete
    
    Note over User,ChatView: User navigates to different thread
    
    User->>ChatView: Switch back to original thread
    ChatView->>DraftStore: usePendingUserInputThreadDraft(threadId)
    DraftStore->>Storage: hydrate from localStorage
    Storage-->>DraftStore: draft data
    DraftStore-->>ChatView: return draft (answersByRequestId, questionIndexByRequestId)
    ChatView->>ComposerPanel: Pass restored answers and question index
    ComposerPanel-->>User: Display restored draft state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 Drafts now persist through thread-hopping sprees,
No more lost answers, just memories to keep!
The cursor dances, the options align,
Your clarifying questions? Now they're truly thine! 🌱✨

🚥 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 PR title clearly describes the main changes: persisting pending plan input drafts and stabilizing preset answers, which directly aligns with the primary objectives.
Description check ✅ Passed The PR description comprehensively covers all required sections: What Changed, Why, UI Changes, and Checklist. It provides clear problem statements, detailed explanations of the solution, videos demonstrating before/after behavior, and proper issue references.
Linked Issues check ✅ Passed All coding requirements from linked issues are met: #375 (cursor navigation in custom input via draft isolation), #918 (persistence across thread switches via Zustand store), #528 (editable input and answer preservation via answerSource tracking and draft storage).
Out of Scope Changes check ✅ Passed All changes are scoped to the pending-input workflow. New store, type definitions, component logic updates, and tests all directly address the linked issues without introducing unrelated functionality.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 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

You can make CodeRabbit's review stricter and more nitpicky using the `assertive` profile, if that's what you prefer.

Change the reviews.profile setting to assertive to make CodeRabbit's nitpick more issues in your PRs.

@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

Comment on lines +406 to +419
{
...baseSnapshot.threads[0]!,
id: SECOND_THREAD_ID,
title: "Secondary thread",
activities: [],
messages: [
createUserMessage({
id: "msg-user-second-thread" as MessageId,
text: "secondary thread",
offsetSeconds: 700,
}),
],
updatedAt: isoAt(701),
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix the secondary thread fixture’s session ID.

This block copies baseSnapshot.threads[0]!.session unchanged, so the secondary thread still reports session.threadId === THREAD_ID. Any code that keys off the session object will see an impossible snapshot, which can mask navigation regressions in these browser tests.

💡 Suggested fix
       {
         ...baseSnapshot.threads[0]!,
         id: SECOND_THREAD_ID,
         title: "Secondary thread",
+        session: {
+          ...baseSnapshot.threads[0]!.session,
+          threadId: SECOND_THREAD_ID,
+        },
         activities: [],
         messages: [
           createUserMessage({
             id: "msg-user-second-thread" as MessageId,
             text: "secondary thread",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
...baseSnapshot.threads[0]!,
id: SECOND_THREAD_ID,
title: "Secondary thread",
activities: [],
messages: [
createUserMessage({
id: "msg-user-second-thread" as MessageId,
text: "secondary thread",
offsetSeconds: 700,
}),
],
updatedAt: isoAt(701),
},
{
...baseSnapshot.threads[0]!,
id: SECOND_THREAD_ID,
title: "Secondary thread",
session: {
...baseSnapshot.threads[0]!.session,
threadId: SECOND_THREAD_ID,
},
activities: [],
messages: [
createUserMessage({
id: "msg-user-second-thread" as MessageId,
text: "secondary thread",
offsetSeconds: 700,
}),
],
updatedAt: isoAt(701),
},
🤖 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 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).

Comment on lines +666 to +668
useEffect(() => {
clearInactivePendingUserInputDraftRequests(threadId, activePendingUserInputRequestIds);
}, [activePendingUserInputRequestIds, clearInactivePendingUserInputDraftRequests, threadId]);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t prune persisted pending-input drafts before the thread has loaded.

On a cold load, pendingUserInputs is [] until the snapshot hydrates. This effect then calls clearInactiveRequests(threadId, []) and deletes the restored localStorage draft before the pending request appears, so refresh/startup loses the answers this PR is trying to persist.

💡 Suggested fix
   useEffect(() => {
+    if (!activeThread) {
+      return;
+    }
     clearInactivePendingUserInputDraftRequests(threadId, activePendingUserInputRequestIds);
-  }, [activePendingUserInputRequestIds, clearInactivePendingUserInputDraftRequests, threadId]);
+  }, [
+    activePendingUserInputRequestIds,
+    activeThread,
+    clearInactivePendingUserInputDraftRequests,
+    threadId,
+  ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
clearInactivePendingUserInputDraftRequests(threadId, activePendingUserInputRequestIds);
}, [activePendingUserInputRequestIds, clearInactivePendingUserInputDraftRequests, threadId]);
useEffect(() => {
if (!activeThread) {
return;
}
clearInactivePendingUserInputDraftRequests(threadId, activePendingUserInputRequestIds);
}, [
activePendingUserInputRequestIds,
activeThread,
clearInactivePendingUserInputDraftRequests,
threadId,
]);
🤖 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 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.

Comment on lines +56 to 68
const normalizedCustomAnswer = normalizeDraftAnswer(customAnswer);
const selectedOptionLabel = normalizedCustomAnswer ? undefined : draft?.selectedOptionLabel;
const answerSource = normalizedCustomAnswer
? "custom"
: selectedOptionLabel
? "option"
: undefined;

return {
...(answerSource ? { answerSource } : {}),
customAnswer,
...(selectedOptionLabel ? { selectedOptionLabel } : {}),
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Collapse whitespace-only custom drafts to "" before storing them.

normalizeDraftAnswer() already treats whitespace-only input as unanswered, but this still persists the raw spaces in customAnswer. That leaves blank-looking drafts in the store after navigation and makes any caller that inspects customAnswer directly easier to desync from the new source semantics.

💡 Suggested fix
 export function setPendingUserInputCustomAnswer(
   draft: PendingUserInputDraftAnswer | undefined,
   customAnswer: string,
 ): PendingUserInputDraftAnswer {
   const normalizedCustomAnswer = normalizeDraftAnswer(customAnswer);
+  const storedCustomAnswer = normalizedCustomAnswer === null ? "" : customAnswer;
   const selectedOptionLabel = normalizedCustomAnswer ? undefined : draft?.selectedOptionLabel;
   const answerSource = normalizedCustomAnswer
     ? "custom"
     : selectedOptionLabel
       ? "option"
       : undefined;
 
   return {
     ...(answerSource ? { answerSource } : {}),
-    customAnswer,
+    customAnswer: storedCustomAnswer,
     ...(selectedOptionLabel ? { selectedOptionLabel } : {}),
   };
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const normalizedCustomAnswer = normalizeDraftAnswer(customAnswer);
const selectedOptionLabel = normalizedCustomAnswer ? undefined : draft?.selectedOptionLabel;
const answerSource = normalizedCustomAnswer
? "custom"
: selectedOptionLabel
? "option"
: undefined;
return {
...(answerSource ? { answerSource } : {}),
customAnswer,
...(selectedOptionLabel ? { selectedOptionLabel } : {}),
};
const normalizedCustomAnswer = normalizeDraftAnswer(customAnswer);
const storedCustomAnswer = normalizedCustomAnswer === null ? "" : customAnswer;
const selectedOptionLabel = normalizedCustomAnswer ? undefined : draft?.selectedOptionLabel;
const answerSource = normalizedCustomAnswer
? "custom"
: selectedOptionLabel
? "option"
: undefined;
return {
...(answerSource ? { answerSource } : {}),
customAnswer: storedCustomAnswer,
...(selectedOptionLabel ? { selectedOptionLabel } : {}),
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/pendingUserInput.ts` around lines 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).

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