Extract ChatComposer to fix composer keystroke re-renders#1857
Extract ChatComposer to fix composer keystroke re-renders#1857juliusmarminge merged 11 commits intomainfrom
Conversation
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>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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.shiftKeyguard on the Enter key handler in ChatComposer so Shift+Enter inserts a newline instead of sending.
- Restored the
- ✅ 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
runtimeModeConfigandformatOutgoingPromptnow 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
hasThreadStarteddiverges from canonical check- Replaced the inline hasThreadStarted check in ChatComposer with the canonical threadHasStarted() utility from ChatView.logic.ts that also checks latestTurn.
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.
ApprovabilityVerdict: 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
There was a problem hiding this comment.
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.lengthtoremoval.cursorin bothcollapseExpandedComposerCursoranddetectComposerTriggercalls so the cursor stays at the removal site instead of jumping to the end.
- Changed
- ✅ Fixed: Unused
toggleRuntimeModecallback is dead code- Removed the unused
toggleRuntimeModeuseCallback from ChatView, the prop from ChatComposerProps, and the underscore-prefixed destructuring in ChatComposer.
- Removed the unused
- ✅ Fixed: Command menu disabled during pending user input editing
- Extended
resetCursorStatewith an optionaldetectTriggerparameter and updatedonChangeActivePendingUserInputCustomAnswerto passexpandedCursorandcursorAdjacentToMentionthrough it, restoring trigger detection during pending input editing.
- Extended
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.
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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.
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
There was a problem hiding this comment.
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.
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.
- Pass prompt context into cursor resets - Recompute trigger after send/retry mutations
apps/web/src/components/ChatView.tsx
Outdated
| const ctxSelectedModelSelection = sendCtx?.selectedModelSelection ?? { | ||
| provider: ctxSelectedProvider, | ||
| model: ctxSelectedModel, | ||
| }; |
There was a problem hiding this comment.
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)
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
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
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.
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.modelPickerIconClassNameYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 91a2297. Configure here.
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



Summary
ChatComposer— memo'd component that owns theprompt/images/terminalContextsstore 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.ExpandedImageDialog— self-contained memo'd image preview modal with keyboard navigation.BranchToolbarinmemoand stabilizeonRevertUserMessage/onRunProjectScriptcallbacks so already-memo'd children properly skip re-renders.ChatView shrinks from ~4,900 → ~3,500 lines.
Test plan
🤖 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
ChatComposercomponent and rewiresChatViewto 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
ChatViewand centralizes provider-locking via newderiveLockedProvider.UI perf/structure tweaks: wraps
BranchToolbarinmemo, stabilizes a few callbacks, extracts the expanded image modal intoExpandedImageDialog, 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
ChatViewnow communicates withChatComposervia an imperative ref (ChatComposerHandle) exposingfocusAtEnd,focusAt,readSnapshot,resetCursorState,addTerminalContext, andgetSendContext; send flows callgetSendContext()at send-time instead of reading local state.ExpandedImageDialogas a dedicated memoized component replacing the inline image overlay and keyboard handlers inChatView.deriveLockedProviderin ChatView.logic.ts to replace manual provider lock resolution.useComposerDraftModelStatein composerDraftStore.ts so hooks subscribe only toactiveProviderandmodelSelectionByProviderinstead of the entire draft object, reducing unnecessary re-renders.Macroscope summarized 03ebf72.