Skip to content

Extract ChatComposer to fix composer keystroke re-renders#1857

Merged
juliusmarminge merged 11 commits intomainfrom
feature/chat-composer-extraction
Apr 9, 2026
Merged

Extract ChatComposer to fix composer keystroke re-renders#1857
juliusmarminge merged 11 commits intomainfrom
feature/chat-composer-extraction

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 9, 2026

Summary

  • Extract ChatComposer — memo'd component that owns the prompt/images/terminalContexts store subscriptions and all composer-local state. ChatView now uses granular Zustand selectors (runtimeMode, interactionMode, activeProvider) that don't change on keystroke, preventing cascading re-renders to MessagesTimeline, ChatHeader, BranchToolbar, PlanSidebar, and terminal drawers.
  • Extract ExpandedImageDialog — self-contained memo'd image preview modal with keyboard navigation.
  • Wrap BranchToolbar in memo and stabilize onRevertUserMessage / onRunProjectScript callbacks so already-memo'd children properly skip re-renders.

ChatView shrinks from ~4,900 → ~3,500 lines.

Test plan

  • Type in the composer — verify MessagesTimeline, ChatHeader, BranchToolbar do NOT re-render (use React DevTools Profiler)
  • Send messages, interrupt, approval flows all still work
  • Image attach/paste/drag-drop in composer works
  • Terminal context insertion from terminal drawer works
  • Model/provider picker works
  • Runtime mode 3-state selector works (supervised / auto-accept / full access)
  • Plan mode toggle & plan sidebar work
  • Expanded image dialog (keyboard nav, close) works
  • Branch toolbar env/branch selectors work
  • Draft threads and PR checkout flow work

🤖 Generated with Claude Code


Note

Medium Risk
Moderate risk due to large refactor of chat composer UI/state flow and send logic now pulling state via an imperative ref, which could introduce subtle regressions in attachments, slash commands, pending approvals, and cursor/trigger behavior.

Overview
Extracts the chat input into a new memoized ChatComposer component and rewires ChatView to use an imperative ref (getSendContext, resetCursorState, focus helpers) plus granular Zustand selectors, reducing keystroke-driven re-renders in the main chat view.

Moves composer responsibilities (prompt editing, model/provider picker, slash/path command menu, image attach/persist/preview, terminal-context insertion, pending approvals/user inputs, footer compact layout logic) out of ChatView and centralizes provider-locking via new deriveLockedProvider.

UI perf/structure tweaks: wraps BranchToolbar in memo, stabilizes a few callbacks, extracts the expanded image modal into ExpandedImageDialog, and refines sidebar thread-jump hint key handling to avoid redundant state updates.

Reviewed by Cursor Bugbot for commit 03ebf72. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Extract ChatComposer to fix composer keystroke re-renders

  • Moves all composer state, UI, and logic (command menus, image attachments, terminal contexts, provider/model selection, pending approvals) out of ChatView.tsx into a new ChatComposer.tsx component to prevent full re-renders on every keystroke.
  • ChatView now communicates with ChatComposer via an imperative ref (ChatComposerHandle) exposing focusAtEnd, focusAt, readSnapshot, resetCursorState, addTerminalContext, and getSendContext; send flows call getSendContext() at send-time instead of reading local state.
  • Adds ExpandedImageDialog as a dedicated memoized component replacing the inline image overlay and keyboard handlers in ChatView.
  • Introduces deriveLockedProvider in ChatView.logic.ts to replace manual provider lock resolution.
  • Adds useComposerDraftModelState in composerDraftStore.ts so hooks subscribe only to activeProvider and modelSelectionByProvider instead of the entire draft object, reducing unnecessary re-renders.

Macroscope summarized 03ebf72.

Typing in the composer caused the entire ChatView (~4900 lines) to
re-render on every keystroke because `useComposerThreadDraft` subscribed
to the full draft including `prompt`. This cascaded to MessagesTimeline,
ChatHeader, BranchToolbar, PlanSidebar, and terminal drawers.

- Extract `ChatComposer` (memo'd) that owns the prompt/images/terminal
  context store subscriptions, all composer-local state, and derived
  values (menu items, provider state, send state, debounced queries).
  ChatView now uses granular store selectors for only `runtimeMode`,
  `interactionMode`, and `activeProvider` — none of which change on
  keystroke.

- Extract `ExpandedImageDialog` (memo'd) for the image preview modal.

- Wrap `BranchToolbar` in `memo` and stabilize `onRevertUserMessage`
  with `useCallback` so already-memo'd children (MessagesTimeline,
  ChatHeader) properly skip re-renders.

- Expose `ChatComposerHandle` for cross-cutting operations (focus,
  cursor reset, terminal context insertion, send context).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 9, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d9fe5f16-b056-4207-8e11-94afef403262

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/chat-composer-extraction

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

@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 Apr 9, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 5 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 5 issues found in the latest run.

  • ✅ Fixed: Debug script react-scan shipped in production HTML
    • Removed the react-scan script tag from index.html that was loading an external profiling tool for all users.
  • ✅ Fixed: Shift+Enter now sends instead of inserting newline
    • Restored the !event.shiftKey guard on the Enter key handler in ChatComposer so Shift+Enter inserts a newline instead of sending.
  • ✅ Fixed: Send failure loses images and terminal contexts
    • Added recovery logic in the onSend catch block to restore images (via cloneComposerImageForRetry/addImages) and terminal contexts (via setTerminalContexts) from pre-send snapshots.
  • ✅ Fixed: Duplicate runtimeModeConfig and formatOutgoingPrompt now unused in ChatView
    • Removed dead runtimeModeConfig/runtimeModeOptions from ChatView.tsx, removed unused formatOutgoingPrompt and cloneComposerImageForRetry from ChatComposer.tsx, and cleaned up orphaned imports.
  • ✅ Fixed: ChatComposer's hasThreadStarted diverges from canonical check
    • Replaced the inline hasThreadStarted check in ChatComposer with the canonical threadHasStarted() utility from ChatView.logic.ts that also checks latestTurn.

Create PR

Or push these changes by commenting:

@cursor push 893a2af32b
Preview (893a2af32b)
diff --git a/apps/web/index.html b/apps/web/index.html
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -8,7 +8,6 @@
     <meta name="theme-color" content="#161616" />
     <link rel="icon" href="/favicon.ico" sizes="48x48" />
     <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
-    <script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>
     <script>
       (() => {
         const LIGHT_BACKGROUND = "#ffffff";

diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -37,10 +37,8 @@
 import { isElectron } from "../env";
 import { readLocalApi } from "../localApi";
 import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
+import { parseStandaloneComposerSlashCommand } from "../composer-logic";
 import {
-  parseStandaloneComposerSlashCommand,
-} from "../composer-logic";
-import {
   deriveCompletionDividerBeforeEntryId,
   derivePendingApprovals,
   derivePendingUserInputs,
@@ -93,16 +91,7 @@
 import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
 import PlanSidebar from "./PlanSidebar";
 import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
-import {
-  ChevronDownIcon,
-  ChevronLeftIcon,
-  ChevronRightIcon,
-  type LucideIcon,
-  LockIcon,
-  LockOpenIcon,
-  PenLineIcon,
-  XIcon,
-} from "lucide-react";
+import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react";
 import { Button } from "./ui/button";
 import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
 import { cn, randomUUID } from "~/lib/utils";
@@ -115,10 +104,7 @@
   projectScriptIdFromCommand,
 } from "~/projectScripts";
 import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils";
-import {
-  getProviderModelCapabilities,
-  resolveSelectableProvider,
-} from "../providerModels";
+import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
 import { useSettings } from "../hooks/useSettings";
 import { resolveAppModelSelection } from "../modelSelection";
 import { isTerminalFocused } from "../lib/terminalFocus";
@@ -164,6 +150,7 @@
   LastInvokedScriptByProjectSchema,
   type LocalDispatchSnapshot,
   PullRequestDialogState,
+  cloneComposerImageForRetry,
   readFileAsDataUrl,
   reconcileMountedTerminalThreadIds,
   revokeBlobPreviewUrl,
@@ -338,29 +325,6 @@
   worktreePath: string | null;
 }
 
-const runtimeModeConfig: Record<
-  RuntimeMode,
-  { label: string; description: string; icon: LucideIcon }
-> = {
-  "approval-required": {
-    label: "Supervised",
-    description: "Ask before commands and file changes.",
-    icon: LockIcon,
-  },
-  "auto-accept-edits": {
-    label: "Auto-accept edits",
-    description: "Auto-approve edits, ask before other actions.",
-    icon: PenLineIcon,
-  },
-  "full-access": {
-    label: "Full access",
-    description: "Allow commands and edits without prompts.",
-    icon: LockOpenIcon,
-  },
-};
-
-const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
-
 type PersistentTerminalLaunchContext = Pick<TerminalLaunchContext, "cwd" | "worktreePath">;
 
 function useLocalDispatchState(input: {
@@ -647,14 +611,16 @@
     (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,
   );
   const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
-  const setComposerDraftModelSelection = useComposerDraftStore(
-    (store) => store.setModelSelection,
-  );
+  const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection);
   const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode);
   const setComposerDraftInteractionMode = useComposerDraftStore(
     (store) => store.setInteractionMode,
   );
   const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
+  const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
+  const setComposerDraftTerminalContexts = useComposerDraftStore(
+    (store) => store.setTerminalContexts,
+  );
   const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
   const getDraftSessionByLogicalProjectKey = useComposerDraftStore(
     (store) => store.getDraftSessionByLogicalProjectKey,
@@ -810,8 +776,7 @@
   );
   const isServerThread = routeKind === "server" && serverThread !== undefined;
   const activeThread = isServerThread ? serverThread : localDraftThread;
-  const runtimeMode =
-    composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
+  const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
   const interactionMode =
     composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
   const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
@@ -1565,12 +1530,9 @@
       focusComposer();
     });
   }, [focusComposer]);
-  const addTerminalContextToDraft = useCallback(
-    (selection: TerminalContextSelection) => {
-      composerRef.current?.addTerminalContext(selection);
-    },
-    [],
-  );
+  const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => {
+    composerRef.current?.addTerminalContext(selection);
+  }, []);
   const setTerminalOpen = useCallback(
     (open: boolean) => {
       if (!activeThreadRef) return;
@@ -2475,7 +2437,15 @@
     }
     const sendCtx = composerRef.current?.getSendContext();
     if (!sendCtx) return;
-    const { images: composerImages, terminalContexts: composerTerminalContexts, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection } = sendCtx;
+    const {
+      images: composerImages,
+      terminalContexts: composerTerminalContexts,
+      selectedProvider: ctxSelectedProvider,
+      selectedModel: ctxSelectedModel,
+      selectedProviderModels: ctxSelectedProviderModels,
+      selectedPromptEffort: ctxSelectedPromptEffort,
+      selectedModelSelection: ctxSelectedModelSelection,
+    } = sendCtx;
     const promptForSend = promptRef.current;
     const {
       trimmedPrompt: trimmed,
@@ -2635,7 +2605,9 @@
           ctxSelectedModel ||
           activeProject.defaultModelSelection?.model ||
           DEFAULT_MODEL_BY_PROVIDER.codex,
-        ...(ctxSelectedModelSelection.options ? { options: ctxSelectedModelSelection.options } : {}),
+        ...(ctxSelectedModelSelection.options
+          ? { options: ctxSelectedModelSelection.options }
+          : {}),
       };
 
       // Auto-title from first message
@@ -2724,6 +2696,13 @@
         });
         promptRef.current = promptForSend;
         setComposerDraftPrompt(composerDraftTarget, promptForSend);
+        if (composerImagesSnapshot.length > 0) {
+          const clonedImages = composerImagesSnapshot.map(cloneComposerImageForRetry);
+          addComposerDraftImages(composerDraftTarget, clonedImages);
+        }
+        if (composerTerminalContextsSnapshot.length > 0) {
+          setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot);
+        }
       }
       setThreadError(
         threadIdForSend,
@@ -2932,7 +2911,10 @@
       const ctxSelectedModel = sendCtx?.selectedModel ?? "";
       const ctxSelectedProviderModels = sendCtx?.selectedProviderModels ?? [];
       const ctxSelectedPromptEffort = sendCtx?.selectedPromptEffort ?? null;
-      const ctxSelectedModelSelection = sendCtx?.selectedModelSelection ?? { provider: ctxSelectedProvider, model: ctxSelectedModel };
+      const ctxSelectedModelSelection = sendCtx?.selectedModelSelection ?? {
+        provider: ctxSelectedProvider,
+        model: ctxSelectedModel,
+      };
 
       const threadIdForSend = activeThread.id;
       const messageIdForSend = newMessageId();
@@ -3059,7 +3041,10 @@
     const ctxSelectedModel = sendCtx?.selectedModel ?? "";
     const ctxSelectedProviderModels = sendCtx?.selectedProviderModels ?? [];
     const ctxSelectedPromptEffort = sendCtx?.selectedPromptEffort ?? null;
-    const ctxSelectedModelSelection = sendCtx?.selectedModelSelection ?? { provider: ctxSelectedProvider, model: ctxSelectedModel };
+    const ctxSelectedModelSelection = sendCtx?.selectedModelSelection ?? {
+      provider: ctxSelectedProvider,
+      model: ctxSelectedModel,
+    };
 
     const createdAt = new Date().toISOString();
     const nextThreadId = newThreadId();
@@ -3414,7 +3399,9 @@
               onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption}
               onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
               onPreviousActivePendingUserInputQuestion={onPreviousActivePendingUserInputQuestion}
-              onChangeActivePendingUserInputCustomAnswer={onChangeActivePendingUserInputCustomAnswer}
+              onChangeActivePendingUserInputCustomAnswer={
+                onChangeActivePendingUserInputCustomAnswer
+              }
               onProviderModelSelect={onProviderModelSelect}
               toggleInteractionMode={toggleInteractionMode}
               toggleRuntimeMode={toggleRuntimeMode}

diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -1,6 +1,5 @@
 import type {
   ApprovalRequestId,
-  ClaudeCodeEffort,
   EnvironmentId,
   ModelSelection,
   ProjectEntry,
@@ -19,7 +18,7 @@
   ProviderInteractionMode as ProviderInteractionModeEnum,
 } from "@t3tools/contracts";
 import { scopeThreadRef } from "@t3tools/client-runtime";
-import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model";
+import { normalizeModelSlug } from "@t3tools/shared/model";
 import {
   forwardRef,
   memo,
@@ -42,12 +41,8 @@
   expandCollapsedComposerCursor,
   replaceTextRange,
 } from "../../composer-logic";
+import { deriveComposerSendState, readFileAsDataUrl, threadHasStarted } from "../ChatView.logic";
 import {
-  deriveComposerSendState,
-  readFileAsDataUrl,
-  cloneComposerImageForRetry,
-} from "../ChatView.logic";
-import {
   type ComposerImageAttachment,
   type DraftId,
   type PersistedComposerImageAttachment,
@@ -101,7 +96,7 @@
   XIcon,
 } from "lucide-react";
 import { proposedPlanTitle } from "../../proposedPlan";
-import { resolveSelectableProvider, getProviderModels, getProviderModelCapabilities } from "../../providerModels";
+import { resolveSelectableProvider, getProviderModels } from "../../providerModels";
 import { resolveAppModelSelection } from "../../modelSelection";
 import type { UnifiedSettings } from "@t3tools/contracts/settings";
 import type { SessionPhase, Thread } from "../../types";
@@ -165,20 +160,6 @@
 ): boolean =>
   contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
 
-function formatOutgoingPrompt(params: {
-  provider: ProviderKind;
-  model: string | null;
-  models: ReadonlyArray<ServerProvider["models"][number]>;
-  effort: string | null;
-  text: string;
-}): string {
-  const caps = getProviderModelCapabilities(params.models, params.model, params.provider);
-  if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) {
-    return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null);
-  }
-  return params.text;
-}
-
 // --------------------------------------------------------------------------
 // Handle exposed to ChatView
 // --------------------------------------------------------------------------
@@ -432,7 +413,7 @@
     const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
     const threadProvider =
       activeThreadModelSelection?.provider ?? activeProjectDefaultModelSelection?.provider ?? null;
-    const hasThreadStarted = activeThread ? (activeThread.messages?.length ?? 0) > 0 || activeThread.session !== null : false;
+    const hasThreadStarted = threadHasStarted(activeThread);
     const sessionProvider = activeThread?.session?.provider ?? null;
     const computedLockedProvider: ProviderKind | null = hasThreadStarted
       ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)
@@ -1220,7 +1201,7 @@
           return true;
         }
       }
-      if (key === "Enter") {
+      if (key === "Enter" && !event.shiftKey) {
         void onSend();
         return true;
       }
@@ -1577,8 +1558,7 @@
                 onPaste={onComposerPaste}
                 placeholder={
                   isComposerApprovalState
-                    ? (activePendingApproval?.detail ??
-                      "Resolve this approval request to continue")
+                    ? (activePendingApproval?.detail ?? "Resolve this approval request to continue")
                     : activePendingProgress
                       ? "Type your own answer, or leave this blank to use the selected option"
                       : showPlanFollowUpPrompt && activeProposedPlan
@@ -1637,9 +1617,7 @@
 
                   {isComposerFooterCompact ? (
                     <CompactComposerControlsMenu
-                      activePlan={Boolean(
-                        activePlan || sidebarProposedPlan || planSidebarOpen,
-                      )}
+                      activePlan={Boolean(activePlan || sidebarProposedPlan || planSidebarOpen)}
                       interactionMode={interactionMode}
                       planSidebarOpen={planSidebarOpen}
                       runtimeMode={runtimeMode}
@@ -1660,10 +1638,7 @@
                         </>
                       ) : null}
 
-                      <Separator
-                        orientation="vertical"
-                        className="mx-0.5 hidden h-4 sm:block"
-                      />
+                      <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
 
                       <Button
                         variant="ghost"
@@ -1683,10 +1658,7 @@
                         </span>
                       </Button>
 
-                      <Separator
-                        orientation="vertical"
-                        className="mx-0.5 hidden h-4 sm:block"
-                      />
+                      <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
 
                       <Select
                         value={runtimeMode}
@@ -1739,9 +1711,7 @@
                             size="sm"
                             type="button"
                             onClick={togglePlanSidebar}
-                            title={
-                              planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"
-                            }
+                            title={planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
                           >
                             <ListTodoIcon />
                             <span className="sr-only sm:not-sr-only">Plan</span>
@@ -1761,13 +1731,9 @@
                   }
                   className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
                 >
-                  {activeContextWindow ? (
-                    <ContextWindowMeter usage={activeContextWindow} />
-                  ) : null}
+                  {activeContextWindow ? <ContextWindowMeter usage={activeContextWindow} /> : null}
                   {isPreparingWorktree ? (
-                    <span className="text-muted-foreground/70 text-xs">
-                      Preparing worktree...
-                    </span>
+                    <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span>
                   ) : null}
                   <ComposerPrimaryActions
                     compact={isComposerPrimaryActionsCompact}

diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx
--- a/apps/web/src/components/chat/ExpandedImageDialog.tsx
+++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx
@@ -100,9 +100,7 @@
         />
         <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80">
           {item.name}
-          {preview.images.length > 1
-            ? ` (${preview.index + 1}/${preview.images.length})`
-            : ""}
+          {preview.images.length > 1 ? ` (${preview.index + 1}/${preview.images.length})` : ""}
         </p>
       </div>
       {preview.images.length > 1 && (

You can send follow-ups to the cloud agent here.

@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Apr 9, 2026

Approvability

Verdict: Needs human review

This is a large refactoring PR that extracts ChatComposer into its own component (~1900 lines). While mostly mechanical code movement from ChatView.tsx, it introduces new component boundaries and an imperative handle pattern. An open review comment identifies a potential bug where plan implementation callbacks may proceed with empty model values when getSendContext returns undefined, which warrants human attention.

You can customize Macroscope's approvability policy. Learn more.

…-extraction

# Conflicts:
#	apps/web/src/components/ChatView.tsx
- Use the resolved composer draft target when clearing and restoring state
- Keep draft images and terminal contexts intact across sends
- Make Enter send while Shift+Enter inserts a newline
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 3 potential issues.

There are 5 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Terminal context removal cursor jumps to end of prompt
    • Changed removal.prompt.length to removal.cursor in both collapseExpandedComposerCursor and detectComposerTrigger calls so the cursor stays at the removal site instead of jumping to the end.
  • ✅ Fixed: Unused toggleRuntimeMode callback is dead code
    • Removed the unused toggleRuntimeMode useCallback from ChatView, the prop from ChatComposerProps, and the underscore-prefixed destructuring in ChatComposer.
  • ✅ Fixed: Command menu disabled during pending user input editing
    • Extended resetCursorState with an optional detectTrigger parameter and updated onChangeActivePendingUserInputCustomAnswer to pass expandedCursor and cursorAdjacentToMention through it, restoring trigger detection during pending input editing.

Create PR

Or push these changes by commenting:

@cursor push cf5b5dda76
Preview (cf5b5dda76)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -1871,11 +1871,6 @@
   const toggleInteractionMode = useCallback(() => {
     handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
   }, [handleInteractionModeChange, interactionMode]);
-  const toggleRuntimeMode = useCallback(() => {
-    void handleRuntimeModeChange(
-      runtimeMode === "full-access" ? "approval-required" : "full-access",
-    );
-  }, [handleRuntimeModeChange, runtimeMode]);
   const togglePlanSidebar = useCallback(() => {
     setPlanSidebarOpen((open) => {
       if (open) {
@@ -2830,8 +2825,8 @@
       questionId: string,
       value: string,
       nextCursor: number,
-      _expandedCursor: number,
-      _cursorAdjacentToMention: boolean,
+      expandedCursor: number,
+      cursorAdjacentToMention: boolean,
     ) => {
       if (!activePendingUserInput) {
         return;
@@ -2847,7 +2842,10 @@
           ),
         },
       }));
-      composerRef.current?.resetCursorState({ cursor: nextCursor });
+      composerRef.current?.resetCursorState({
+        cursor: nextCursor,
+        detectTrigger: { prompt: value, expandedCursor, cursorAdjacentToMention },
+      });
     },
     [activePendingUserInput],
   );
@@ -3410,7 +3408,6 @@
               }
               onProviderModelSelect={onProviderModelSelect}
               toggleInteractionMode={toggleInteractionMode}
-              toggleRuntimeMode={toggleRuntimeMode}
               handleRuntimeModeChange={handleRuntimeModeChange}
               handleInteractionModeChange={handleInteractionModeChange}
               togglePlanSidebar={togglePlanSidebar}

diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -170,7 +170,14 @@
     terminalContextIds: string[];
   };
   /** Reset composer cursor/trigger/highlight after external prompt mutations (e.g. onSend). */
-  resetCursorState: (options?: { cursor?: number }) => void;
+  resetCursorState: (options?: {
+    cursor?: number;
+    detectTrigger?: {
+      prompt: string;
+      expandedCursor: number;
+      cursorAdjacentToMention: boolean;
+    };
+  }) => void;
   /** Insert a terminal context from the terminal drawer. */
   addTerminalContext: (selection: TerminalContextSelection) => void;
   /** Get the current prompt/effort/model state for use in send. */
@@ -283,7 +290,6 @@
 
   onProviderModelSelect: (provider: ProviderKind, model: string) => void;
   toggleInteractionMode: () => void;
-  toggleRuntimeMode: () => void;
   handleRuntimeModeChange: (mode: RuntimeMode) => void;
   handleInteractionModeChange: (mode: ProviderInteractionMode) => void;
   togglePlanSidebar: () => void;
@@ -354,7 +360,6 @@
       onChangeActivePendingUserInputCustomAnswer,
       onProviderModelSelect,
       toggleInteractionMode,
-      toggleRuntimeMode: _toggleRuntimeMode,
       handleRuntimeModeChange,
       handleInteractionModeChange,
       togglePlanSidebar,
@@ -756,9 +761,9 @@
         promptRef.current = removal.prompt;
         setPrompt(removal.prompt);
         removeComposerDraftTerminalContext(composerDraftTarget, contextId);
-        const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.prompt.length);
+        const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor);
         setComposerCursor(nextCursor);
-        setComposerTrigger(detectComposerTrigger(removal.prompt, removal.prompt.length));
+        setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor));
       },
       [
         composerDraftTarget,
@@ -1304,11 +1309,25 @@
         readSnapshot: () => {
           return readComposerSnapshot();
         },
-        resetCursorState: (options?: { cursor?: number }) => {
+        resetCursorState: (options?: {
+          cursor?: number;
+          detectTrigger?: {
+            prompt: string;
+            expandedCursor: number;
+            cursorAdjacentToMention: boolean;
+          };
+        }) => {
           const cursor = options?.cursor ?? 0;
           setComposerHighlightedItemId(null);
           setComposerCursor(cursor);
-          setComposerTrigger(null);
+          if (options?.detectTrigger) {
+            const { prompt, expandedCursor, cursorAdjacentToMention } = options.detectTrigger;
+            setComposerTrigger(
+              cursorAdjacentToMention ? null : detectComposerTrigger(prompt, expandedCursor),
+            );
+          } else {
+            setComposerTrigger(null);
+          }
         },
         addTerminalContext: (selection: TerminalContextSelection) => {
           if (!activeThread) return;

You can send follow-ups to the cloud agent here.

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 3 potential issues.

There are 4 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Error recovery skips blob URL cloning for retry images
    • Restored cloneComposerImageForRetry mapping on composerImagesSnapshot before passing to addComposerDraftImages and composerImagesRef, so revoked blob URLs are replaced with fresh ones.
  • ✅ Fixed: Pending input custom answer sync effect was removed
    • Re-added the lastSyncedPendingInputRef effect in ChatComposer that syncs promptRef, cursor, trigger, and highlight state when the active pending user input question changes.
  • ✅ Fixed: applyPromptReplacement lost pending user input routing
    • Added a branch in ChatComposer's applyPromptReplacement that routes text replacements through onChangeActivePendingUserInputCustomAnswer when a pending user input question is active, instead of unconditionally calling setPrompt.

Create PR

Or push these changes by commenting:

@cursor push 3ff2e3db1a
Preview (3ff2e3db1a)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -139,6 +139,7 @@
   buildExpiredTerminalContextToastCopy,
   buildLocalDraftThread,
   buildTemporaryWorktreeBranchName,
+  cloneComposerImageForRetry,
   collectUserMessageBlobPreviewUrls,
   createLocalDispatchSnapshot,
   deriveComposerSendState,
@@ -2694,10 +2695,11 @@
           return next.length === existing.length ? existing : next;
         });
         promptRef.current = promptForSend;
-        composerImagesRef.current = composerImagesSnapshot;
+        const clonedImages = composerImagesSnapshot.map(cloneComposerImageForRetry);
+        composerImagesRef.current = clonedImages;
         composerTerminalContextsRef.current = composerTerminalContextsSnapshot;
         setComposerDraftPrompt(composerDraftTarget, promptForSend);
-        addComposerDraftImages(composerDraftTarget, composerImagesSnapshot);
+        addComposerDraftImages(composerDraftTarget, clonedImages);
         setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot);
         composerRef.current?.resetCursorState({ cursor: promptForSend.length });
       }

diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -786,6 +786,52 @@
     }, [composerTerminalContexts, composerTerminalContextsRef]);
 
     // ------------------------------------------------------------------
+    // Sync pending-input custom answer into prompt/cursor state
+    // ------------------------------------------------------------------
+    const lastSyncedPendingInputRef = useRef<{
+      requestId: string | null;
+      questionId: string | null;
+    } | null>(null);
+    useEffect(() => {
+      const nextCustomAnswer = activePendingProgress?.customAnswer;
+      if (typeof nextCustomAnswer !== "string") {
+        lastSyncedPendingInputRef.current = null;
+        return;
+      }
+      const nextRequestId = pendingUserInputs[0]?.requestId ?? null;
+      const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null;
+      const questionChanged =
+        lastSyncedPendingInputRef.current?.requestId !== nextRequestId ||
+        lastSyncedPendingInputRef.current?.questionId !== nextQuestionId;
+      const textChangedExternally = promptRef.current !== nextCustomAnswer;
+
+      lastSyncedPendingInputRef.current = {
+        requestId: nextRequestId,
+        questionId: nextQuestionId,
+      };
+
+      if (!questionChanged && !textChangedExternally) {
+        return;
+      }
+
+      promptRef.current = nextCustomAnswer;
+      const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length);
+      setComposerCursor(nextCursor);
+      setComposerTrigger(
+        detectComposerTrigger(
+          nextCustomAnswer,
+          expandCollapsedComposerCursor(nextCustomAnswer, nextCursor),
+        ),
+      );
+      setComposerHighlightedItemId(null);
+    }, [
+      activePendingProgress?.customAnswer,
+      activePendingProgress?.activeQuestion?.id,
+      pendingUserInputs,
+      promptRef,
+    ]);
+
+    // ------------------------------------------------------------------
     // Composer menu highlight sync
     // ------------------------------------------------------------------
     useEffect(() => {
@@ -1021,7 +1067,18 @@
         const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
         const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);
         promptRef.current = next.text;
-        setPrompt(next.text);
+        const activePendingQuestion = activePendingProgress?.activeQuestion;
+        if (activePendingQuestion && pendingUserInputs.length > 0) {
+          onChangeActivePendingUserInputCustomAnswer(
+            activePendingQuestion.id,
+            next.text,
+            nextCursor,
+            expandCollapsedComposerCursor(next.text, nextCursor),
+            false,
+          );
+        } else {
+          setPrompt(next.text);
+        }
         setComposerCursor(nextCursor);
         setComposerTrigger(
           detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)),
@@ -1031,7 +1088,13 @@
         });
         return true;
       },
-      [promptRef, setPrompt],
+      [
+        activePendingProgress?.activeQuestion,
+        pendingUserInputs.length,
+        onChangeActivePendingUserInputCustomAnswer,
+        promptRef,
+        setPrompt,
+      ],
     );
 
     const readComposerSnapshot = useCallback((): {

You can send follow-ups to the cloud agent here.

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 4 total unresolved issues (including 3 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Send failure recovery sets trigger to null instead of detecting
    • Added a detectTrigger option to resetCursorState that re-derives the composer trigger via detectComposerTrigger, and enabled it in the send failure recovery path so the autocomplete menu properly reappears.

Create PR

Or push these changes by commenting:

@cursor push b463b31538
Preview (b463b31538)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -2696,7 +2696,10 @@
         setComposerDraftPrompt(composerDraftTarget, promptForSend);
         addComposerDraftImages(composerDraftTarget, retryComposerImages);
         setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot);
-        composerRef.current?.resetCursorState({ cursor: promptForSend.length });
+        composerRef.current?.resetCursorState({
+          cursor: promptForSend.length,
+          detectTrigger: true,
+        });
       }
       setThreadError(
         threadIdForSend,

diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -174,7 +174,7 @@
     terminalContextIds: string[];
   };
   /** Reset composer cursor/trigger/highlight after external prompt mutations (e.g. onSend). */
-  resetCursorState: (options?: { cursor?: number }) => void;
+  resetCursorState: (options?: { cursor?: number; detectTrigger?: boolean }) => void;
   /** Insert a terminal context from the terminal drawer. */
   addTerminalContext: (selection: TerminalContextSelection) => void;
   /** Get the current prompt/effort/model state for use in send. */
@@ -1371,11 +1371,16 @@
         readSnapshot: () => {
           return readComposerSnapshot();
         },
-        resetCursorState: (options?: { cursor?: number }) => {
+        resetCursorState: (options?: { cursor?: number; detectTrigger?: boolean }) => {
           const cursor = options?.cursor ?? 0;
           setComposerHighlightedItemId(null);
           setComposerCursor(cursor);
-          setComposerTrigger(null);
+          if (options?.detectTrigger) {
+            const expanded = expandCollapsedComposerCursor(promptRef.current, cursor);
+            setComposerTrigger(detectComposerTrigger(promptRef.current, expanded));
+          } else {
+            setComposerTrigger(null);
+          }
         },
         addTerminalContext: (selection: TerminalContextSelection) => {
           if (!activeThread) return;

You can send follow-ups to the cloud agent here.

- Split footer composer controls into memoized subcomponents
- Separate reusable new-thread logic from route-aware hook
- Add composer draft model selector and thread-jump hint cleanup
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 5 total unresolved issues (including 4 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Removed environment-change model auto-correction logic entirely
    • Restored the prevEnvironmentIdRef useEffect in ChatComposer that persists the resolved provider/model into the draft store when the environment changes, preventing stale model references.

Create PR

Or push these changes by commenting:

@cursor push e20413ef58
Preview (e20413ef58)
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -450,7 +450,7 @@
       routeThreadRef,
       draftId,
       activeThreadId,
-      activeThreadEnvironmentId: _activeThreadEnvironmentId,
+      activeThreadEnvironmentId,
       activeThread,
       isServerThread: _isServerThread,
       isLocalDraftThread: _isLocalDraftThread,
@@ -624,6 +624,42 @@
     );
 
     // ------------------------------------------------------------------
+    // Auto-correct draft model on environment change
+    // ------------------------------------------------------------------
+    const setComposerDraftModelSelection = useComposerDraftStore(
+      (store) => store.setModelSelection,
+    );
+    const envLocked = Boolean(
+      activeThread &&
+      (activeThread.messages.length > 0 ||
+        (activeThread.session !== null && activeThread.session.status !== "closed")),
+    );
+    const prevEnvironmentIdRef = useRef(activeThreadEnvironmentId);
+    useEffect(() => {
+      const currentEnvId = activeThreadEnvironmentId;
+      if (!currentEnvId || envLocked || prevEnvironmentIdRef.current === currentEnvId) {
+        prevEnvironmentIdRef.current = currentEnvId;
+        return;
+      }
+      prevEnvironmentIdRef.current = currentEnvId;
+
+      if (activeThread) {
+        setComposerDraftModelSelection(composerDraftTarget, {
+          provider: selectedProvider,
+          model: selectedModel,
+        });
+      }
+    }, [
+      activeThread,
+      activeThreadEnvironmentId,
+      composerDraftTarget,
+      envLocked,
+      selectedModel,
+      selectedProvider,
+      setComposerDraftModelSelection,
+    ]);
+
+    // ------------------------------------------------------------------
     // Context window
     // ------------------------------------------------------------------
     const activeContextWindow = useMemo(

You can send follow-ups to the cloud agent here.

const ctxSelectedModelSelection = sendCtx?.selectedModelSelection ?? {
provider: ctxSelectedProvider,
model: ctxSelectedModel,
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing fallback model in plan implementation callbacks

Medium Severity

In onImplementSameThreadPlan and onImplementPlanInNewThread, when composerRef.current?.getSendContext() returns undefined, the fallback for ctxSelectedModel is an empty string "" and ctxSelectedProviderModels is []. Unlike onSend which does an early if (!sendCtx) return, these callbacks proceed to dispatch commands with a potentially empty model string in ctxSelectedModelSelection, which could cause server-side errors.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1e4515e. Configure here.

- Use the composer's send context as the source of truth
- Remove fallback to stale selected provider state
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: ChatComposer redundantly re-derives locked provider from parent
    • Removed the redundant computedLockedProvider derivation and effectiveLockedProvider alias; ChatComposer now uses the lockedProvider prop from ChatView directly.

Create PR

Or push these changes by commenting:

@cursor push cf8fa40131
Preview (cf8fa40131)
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -39,12 +39,8 @@
   expandCollapsedComposerCursor,
   replaceTextRange,
 } from "../../composer-logic";
+import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic";
 import {
-  deriveComposerSendState,
-  deriveLockedProvider,
-  readFileAsDataUrl,
-} from "../ChatView.logic";
-import {
   type ComposerImageAttachment,
   type DraftId,
   type PersistedComposerImageAttachment,
@@ -546,18 +542,12 @@
     const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
     const threadProvider =
       activeThreadModelSelection?.provider ?? activeProjectDefaultModelSelection?.provider ?? null;
-    const computedLockedProvider = deriveLockedProvider({
-      thread: activeThread,
-      selectedProvider: selectedProviderByThreadId,
-      threadProvider,
-    });
-    const effectiveLockedProvider = lockedProvider ?? computedLockedProvider;
 
     const unlockedSelectedProvider = resolveSelectableProvider(
       providerStatuses,
       selectedProviderByThreadId ?? threadProvider ?? "codex",
     );
-    const selectedProvider: ProviderKind = effectiveLockedProvider ?? unlockedSelectedProvider;
+    const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider;
 
     const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
       threadRef: composerDraftTarget,
@@ -612,7 +602,7 @@
     const searchableModelOptions = useMemo(
       () =>
         AVAILABLE_PROVIDER_OPTIONS.filter(
-          (option) => effectiveLockedProvider === null || option.value === effectiveLockedProvider,
+          (option) => lockedProvider === null || option.value === lockedProvider,
         ).flatMap((option) =>
           modelOptionsByProvider[option.value].map(({ slug, name }) => ({
             provider: option.value,
@@ -624,7 +614,7 @@
             searchProvider: option.label.toLowerCase(),
           })),
         ),
-      [effectiveLockedProvider, modelOptionsByProvider],
+      [lockedProvider, modelOptionsByProvider],
     );
 
     // ------------------------------------------------------------------
@@ -1825,7 +1815,7 @@
                     compact={isComposerFooterCompact}
                     provider={selectedProvider}
                     model={selectedModelForPickerWithCustomFallback}
-                    lockedProvider={effectiveLockedProvider}
+                    lockedProvider={lockedProvider}
                     providers={providerStatuses}
                     modelOptionsByProvider={modelOptionsByProvider}
                     {...(composerProviderState.modelPickerIconClassName

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 91a2297. Configure here.

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push cf8fa40

ChatComposer was re-deriving lockedProvider via deriveLockedProvider with
the same inputs already used by ChatView, then combining both via
effectiveLockedProvider = lockedProvider ?? computedLockedProvider.
Since both always agreed, computedLockedProvider was dead code.

Now ChatComposer trusts the lockedProvider prop from its parent directly.

Applied via @cursor push command
@juliusmarminge juliusmarminge merged commit 869789b into main Apr 9, 2026
12 checks passed
@juliusmarminge juliusmarminge deleted the feature/chat-composer-extraction branch April 9, 2026 23:37
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

Development

Successfully merging this pull request may close these issues.

2 participants