diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b0d1804feffa..f87663989a8f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -24,6 +24,7 @@ import { } from "./ChatView.logic"; import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; import { useScratchProject } from "../hooks/useScratchProject"; +import { useStableCallback } from "../hooks/useStableCallback"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import { @@ -203,6 +204,7 @@ import { findLatestProposedPlan, hasActionableProposedPlan, isLatestRunSettled, + type PendingUserInput, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; import { @@ -1578,6 +1580,110 @@ function releaseChatTimelineAnchor(); +// The factories below build functions ChatView calls during render, or hands +// to ChatComposer, which can call them after an await. They live outside the +// component so each closure keeps only its inputs, not a whole render scope +// (see useStableCallback), while still acting on the render that made them. + +/** Records a thread error in local state, keyed by the route the making render saw. */ +function makeThreadErrorSetter(input: { + readonly routeThreadRef: ScopedThreadRef; + readonly routeThreadKey: string; + readonly draftId: DraftId | null; + readonly serverThreadRef: ScopedThreadRef | null; + readonly setLocalServerErrorsByThreadKey: ( + update: SetStateAction>, + ) => void; + readonly setLocalDraftErrorsByDraftId: ( + update: SetStateAction>, + ) => void; +}) { + return (targetThreadId: ThreadId | null, error: string | null) => { + if (!targetThreadId) return; + const nextError = sanitizeThreadErrorMessage(error); + const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; + const { routeThreadRef, serverThreadRef } = input; + if ( + serverThreadRef && + targetThreadId === routeThreadRef.threadId && + serverThreadRef.environmentId === routeThreadRef.environmentId && + serverThreadRef.threadId === targetThreadId + ) { + input.setLocalServerErrorsByThreadKey((existing) => { + if ((existing[input.routeThreadKey]?.message ?? null) === nextError) { + return existing; + } + return { ...existing, [input.routeThreadKey]: nextEntry }; + }); + return; + } + const localDraftErrorKey = input.draftId ?? targetThreadId; + input.setLocalDraftErrorsByDraftId((existing) => { + if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { + return existing; + } + return { ...existing, [localDraftErrorKey]: nextEntry }; + }); + }; +} + +/** Writes the custom answer for a question of the pending user input the making render saw. */ +function makePendingCustomAnswerSetter(input: { + readonly activePendingUserInput: PendingUserInput | null; + readonly activePendingRequestKey: string; + readonly promptRef: { current: string }; + readonly setPendingUserInputAnswersByRequestId: ( + update: SetStateAction>>, + ) => void; +}) { + return (questionId: string, value: string) => { + const { activePendingUserInput, activePendingRequestKey } = input; + if (!activePendingUserInput) { + return; + } + const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question || question.allowCustomAnswer === false) { + return; + } + input.promptRef.current = value; + input.setPendingUserInputAnswersByRequestId((existing) => ({ + ...existing, + [activePendingRequestKey]: { + ...existing[activePendingRequestKey], + [questionId]: setPendingUserInputCustomAnswer( + existing[activePendingRequestKey]?.[questionId], + value, + question, + ), + }, + })); + // The editor owns this selection. Resetting it before the answer renders + // can re-enter Lexical with offsets from the previous skill token. + }; +} + +/** Draft id for editing a queued run of `threadRef`. */ +function makeQueuedEditDraftTarget(threadRef: ScopedThreadRef) { + return (runId: RunId) => DraftId.make(`queued-edit:${scopedThreadKey(threadRef)}:${runId}`); +} + +/** Why the model picker should disable a model, or null when it can be chosen. */ +function makeModelDisabledReason( + context: Omit< + Parameters[0], + "nextModelSelection" + > | null, +) { + return (instanceId: ProviderInstanceId, model: string): string | null => { + if (!context) return null; + const reason = getStartedThreadModelChangeBlockReason({ + ...context, + nextModelSelection: { instanceId, model }, + }); + return reason ? `${reason.description} Start a new thread to use this model.` : null; + }; +} + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -1589,10 +1695,8 @@ export default function ChatView(props: ChatViewProps) { } = props; const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); - const hasTerminalWriteAccess = useCallback( - () => readEnvironmentScope(environmentId, AuthTerminalOperateScope), - [environmentId], - ); + const hasTerminalWriteAccess = () => + readEnvironmentScope(environmentId, AuthTerminalOperateScope); const canReadTerminal = useEnvironmentScope(environmentId, AuthTerminalReadScope); const draftId = routeKind === "draft" ? props.draftId : null; const handleNewThread = useNewThreadHandler(); @@ -1693,8 +1797,8 @@ export default function ChatView(props: ChatViewProps) { readonly existingAttachments: ReadonlyArray; readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined; } | null>(null); - const queuedEditDraftTargetFor = useCallback( - (runId: RunId) => DraftId.make(`queued-edit:${scopedThreadKey(routeThreadRef)}:${runId}`), + const queuedEditDraftTargetFor = useMemo( + () => makeQueuedEditDraftTarget(routeThreadRef), [routeThreadRef], ); const baseComposerDraftTarget: ScopedThreadRef | DraftId = @@ -1858,21 +1962,21 @@ export default function ChatView(props: ChatViewProps) { const [restingComposerControlsHost, setRestingComposerControlsHost] = useState(null); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); - const citeAssistantText = useCallback( - (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => { - const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false; - if (!inserted) { - toastManager.add({ - type: "warning", - title: "The composer is not ready", - description: - "Try citing the selection after the connection or pending input is resolved.", - }); - } - return inserted; - }, - [composerRef], - ); + const citeAssistantText = ( + citation: AssistantCitation, + sourceAnchor: AssistantCitationSourceAnchor, + ) => { + const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false; + if (!inserted) { + toastManager.add({ + type: "warning", + title: "The composer is not ready", + description: "Try citing the selection after the connection or pending input is resolved.", + }); + } + return inserted; + }; + const citeAssistantTextStable = useStableCallback(citeAssistantText); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [expandedImage, setExpandedImage] = useState(null); @@ -1973,10 +2077,8 @@ export default function ChatView(props: ChatViewProps) { LastInvokedScriptByProjectSchema, ); const legendListRef = useRef(null); - const getTimelineScrollableNode = useCallback( - () => legendListRef.current?.getScrollableNode() ?? null, - [], - ); + const getTimelineScrollableNode = () => legendListRef.current?.getScrollableNode() ?? null; + const getTimelineScrollableNodeStable = useStableCallback(getTimelineScrollableNode); const [composerOverlayElement, setComposerOverlayElement] = useState(null); // Space the timeline keeps clear above its end. Tracks the overlay while the // composer is expanded and holds that height while it rests, so the resting @@ -1988,10 +2090,9 @@ export default function ChatView(props: ChatViewProps) { const composerMountedRef = useRef(true); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); const isAtEndRef = useRef(true); - const isTimelineAtLogicalEnd = useCallback( - () => resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current, - [], - ); + const isTimelineAtLogicalEnd = () => + resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current; + const isTimelineAtLogicalEndStable = useStableCallback(isTimelineAtLogicalEnd); // Whether the timeline's rows extend past the viewport above the composer. // The composer only rests when there is reading space to give back. const [timelineOverflows, setTimelineOverflows] = useState(false); @@ -2006,15 +2107,15 @@ export default function ChatView(props: ChatViewProps) { const isResuming = resumingThreadKeys.has(routeThreadKey); const composerSendGenerationRef = useRef(0); const multipleModelSelections = fanoutState.selections; - const setMultipleModelSelections = useCallback( - (selections: SetStateAction | null>) => { - appAtomRegistry.update(fanoutStateAtom, (current) => ({ - ...current, - selections: typeof selections === "function" ? selections(current.selections) : selections, - })); - }, - [fanoutStateAtom], - ); + const setMultipleModelSelections = ( + selections: SetStateAction | null>, + ) => { + appAtomRegistry.update(fanoutStateAtom, (current) => ({ + ...current, + selections: typeof selections === "function" ? selections(current.selections) : selections, + })); + }; + const setMultipleModelSelectionsStable = useStableCallback(setMultipleModelSelections); const multipleModelSelectionsRef = useRef(multipleModelSelections); multipleModelSelectionsRef.current = multipleModelSelections; const uncertainMultipleSubmissionsRef = fanoutState.uncertainSubmissions; @@ -2353,16 +2454,13 @@ export default function ChatView(props: ChatViewProps) { [activeKnownTerminalIds, panelTerminalIds], ); const canReuseTerminal = activeThreadKnownSessionsRaw !== null; - const allocateTerminalId = useCallback( - () => - nextTerminalId( - allocatableActiveTerminalIds, - canReuseTerminal && readEnvironmentScope(environmentId, AuthTerminalReadScope) - ? undefined - : randomUUID(), - ), - [allocatableActiveTerminalIds, canReuseTerminal, environmentId], - ); + const allocateTerminalId = () => + nextTerminalId( + allocatableActiveTerminalIds, + canReuseTerminal && readEnvironmentScope(environmentId, AuthTerminalReadScope) + ? undefined + : randomUUID(), + ); const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable; const rightPanelOpen = rightPanelState.isOpen; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = @@ -2513,25 +2611,23 @@ export default function ChatView(props: ChatViewProps) { const removeClonedProject = useRemoveClonedProject(); // The banner mirrors the server's clone state, so a request that never got // there needs its own feedback. - const runProjectCloneAction = useCallback( - async ( - title: string, - action: () => Promise>, - ): Promise => { - const result = await action(); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }, - [], - ); + const runProjectCloneAction = async ( + title: string, + action: () => Promise>, + ): Promise => { + const result = await action(); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }; + const runProjectCloneActionStable = useStableCallback(runProjectCloneAction); const projectCloneSendBlockReason = activeProjectClone === null ? null @@ -2559,7 +2655,7 @@ export default function ChatView(props: ChatViewProps) { size="xs" variant="ghost" onClick={() => - void runProjectCloneAction("Failed to cancel clone", () => + void runProjectCloneActionStable("Failed to cancel clone", () => cancelProjectClone({ environmentId, input: { projectId } }), ) } @@ -2589,7 +2685,7 @@ export default function ChatView(props: ChatViewProps) { size="xs" variant="ghost" onClick={() => - void runProjectCloneAction("Failed to retry clone", () => + void runProjectCloneActionStable("Failed to retry clone", () => retryProjectClone({ environmentId, input: { projectId } }), ) } @@ -2605,24 +2701,26 @@ export default function ChatView(props: ChatViewProps) { cancelProjectClone, removeClonedProject, retryProjectClone, - runProjectCloneAction, + runProjectCloneActionStable, ]); const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; - const handleNewThreadInActiveProject = useCallback(() => { + const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); - }, [activeProjectRef, handleNewThread]); + }; + const handleNewThreadInActiveProjectStable = useStableCallback(handleNewThreadInActiveProject); const projectGroupingSettings = selectProjectGroupingSettings(settings); const activeDraftLogicalProjectKey = !isServerThread && activeProject ? deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings) : undefined; - const handleOpenDraftProjectSettings = useCallback(() => { + const handleOpenDraftProjectSettings = () => { if (!activeDraftLogicalProjectKey) return; void navigate({ to: "/projects/$projectKey", params: { projectKey: activeDraftLogicalProjectKey }, }); - }, [activeDraftLogicalProjectKey, navigate]); + }; + const handleOpenDraftProjectSettingsStable = useStableCallback(handleOpenDraftProjectSettings); const activeEnvironmentShell = useEnvironmentQuery( activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, ); @@ -2636,24 +2734,22 @@ export default function ChatView(props: ChatViewProps) { const pendingFileSurfaceIds = activeProjectKey ? (pendingFileSurfaceIdsByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS) : EMPTY_PENDING_FILE_SURFACE_IDS; - const handleFilePendingChange = useCallback( - (relativePath: string, pending: boolean) => { - if (!activeProjectKey) return; - setPendingFileSurfaceIdsByProject((currentByProject) => { - const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; - const surfaceId = `file:${relativePath}`; - if (current.has(surfaceId) === pending) return currentByProject; - const next = new Set(current); - if (pending) next.add(surfaceId); - else next.delete(surfaceId); - const nextByProject = new Map(currentByProject); - if (next.size === 0) nextByProject.delete(activeProjectKey); - else nextByProject.set(activeProjectKey, next); - return nextByProject; - }); - }, - [activeProjectKey], - ); + const handleFilePendingChange = (relativePath: string, pending: boolean) => { + if (!activeProjectKey) return; + setPendingFileSurfaceIdsByProject((currentByProject) => { + const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; + const surfaceId = `file:${relativePath}`; + if (current.has(surfaceId) === pending) return currentByProject; + const next = new Set(current); + if (pending) next.add(surfaceId); + else next.delete(surfaceId); + const nextByProject = new Map(currentByProject); + if (next.size === 0) nextByProject.delete(activeProjectKey); + else nextByProject.set(activeProjectKey, next); + return nextByProject; + }); + }; + const handleFilePendingChangeStable = useStableCallback(handleFilePendingChange); const configuredPreviewUrls = useMemo( () => getConfiguredPreviewUrls(activeProjectScripts), [activeProjectScripts], @@ -2707,21 +2803,21 @@ export default function ChatView(props: ChatViewProps) { connection: activeEnvironment.connection, }; }, [activeEnvironment, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel]); - const handleReconnectActiveEnvironment = useCallback( - async (environmentId: EnvironmentId) => { - const result = await retryEnvironment(environmentId); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not reconnect environment", - description: error instanceof Error ? error.message : "Failed to reconnect.", - }), - ); - } - }, - [retryEnvironment], + const handleReconnectActiveEnvironment = async (environmentId: EnvironmentId) => { + const result = await retryEnvironment(environmentId); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not reconnect environment", + description: error instanceof Error ? error.message : "Failed to reconnect.", + }), + ); + } + }; + const handleReconnectActiveEnvironmentStable = useStableCallback( + handleReconnectActiveEnvironment, ); const disconnectDelayElapsed = useEnvironmentDisconnectDelay( activeEnvironmentUnavailable ? activeEnvironment.environmentId : null, @@ -2732,27 +2828,27 @@ export default function ChatView(props: ChatViewProps) { activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" && !isDesktopLocalConnectionTarget(activeEnvironment.entry.target); const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false); - const handleDisconnectActiveEnvironment = useCallback( - async (environmentId: EnvironmentId) => { - setDisconnectingEnvironment(true); - const result = await setEnvironmentEnabled({ environmentId, enabled: false }); - setDisconnectingEnvironment(false); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not disconnect server", - description: error instanceof Error ? error.message : "Failed to disconnect.", - }), - ); - } - return; + const handleDisconnectActiveEnvironment = async (environmentId: EnvironmentId) => { + setDisconnectingEnvironment(true); + const result = await setEnvironmentEnabled({ environmentId, enabled: false }); + setDisconnectingEnvironment(false); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not disconnect server", + description: error instanceof Error ? error.message : "Failed to disconnect.", + }), + ); } - void navigate({ to: "/", replace: true }); - }, - [navigate, setEnvironmentEnabled], + return; + } + void navigate({ to: "/", replace: true }); + }; + const handleDisconnectActiveEnvironmentStable = useStableCallback( + handleDisconnectActiveEnvironment, ); const { scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); const activeProjectIsScratch = @@ -2834,112 +2930,96 @@ export default function ChatView(props: ChatViewProps) { activeEnvironment: activeEnvironmentOption, canPickEnvironment: hasMultipleEnvironments, }); - const openPullRequestDialog = useCallback( - (reference?: string) => { - if (!canCheckoutPullRequestIntoThread) { - return; - } - setPullRequestDialogState({ - initialReference: reference ?? null, - key: Date.now(), - }); - }, - [canCheckoutPullRequestIntoThread], - ); + const openPullRequestDialog = (reference?: string) => { + if (!canCheckoutPullRequestIntoThread) { + return; + } + setPullRequestDialogState({ + initialReference: reference ?? null, + key: Date.now(), + }); + }; + const openPullRequestDialogStable = useStableCallback(openPullRequestDialog); - const closePullRequestDialog = useCallback(() => { + const closePullRequestDialog = () => { setPullRequestDialogState(null); - }, []); - - const openOrReuseProjectDraftThread = useCallback( - async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { - if (!activeProject) { - throw new Error("No active project is available for this pull request."); - } - const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); - const logicalProjectKey = deriveLogicalProjectKeyFromSettings( - activeProject, - projectGroupingSettings, - ); - const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); - if (storedDraftSession) { - setDraftThreadContext(storedDraftSession.draftId, input); - setLogicalProjectDraftThreadId( - logicalProjectKey, - activeProjectRef, - storedDraftSession.draftId, - { - threadId: storedDraftSession.threadId, - ...input, - }, - ); - if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { - await navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(storedDraftSession.draftId), - }); - } - return storedDraftSession.threadId; - } + }; - const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; - if ( - !isServerThread && - activeDraftSession?.logicalProjectKey === logicalProjectKey && - draftId - ) { - setDraftThreadContext(draftId, input); - setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { - threadId: activeDraftSession.threadId, - createdAt: activeDraftSession.createdAt, - runtimeMode: activeDraftSession.runtimeMode, - interactionMode: activeDraftSession.interactionMode, + const openOrReuseProjectDraftThread = async (input: { + branch: string; + worktreePath: string | null; + envMode: DraftThreadEnvMode; + }) => { + if (!activeProject) { + throw new Error("No active project is available for this pull request."); + } + const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); + const logicalProjectKey = deriveLogicalProjectKeyFromSettings( + activeProject, + projectGroupingSettings, + ); + const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); + if (storedDraftSession) { + setDraftThreadContext(storedDraftSession.draftId, input); + setLogicalProjectDraftThreadId( + logicalProjectKey, + activeProjectRef, + storedDraftSession.draftId, + { + threadId: storedDraftSession.threadId, ...input, + }, + ); + if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { + await navigate({ + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(storedDraftSession.draftId), }); - return activeDraftSession.threadId; } + return storedDraftSession.threadId; + } - const nextDraftId = newDraftId(); - const nextThreadId = newThreadId(); - setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { - threadId: nextThreadId, - createdAt: new Date().toISOString(), - runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings - .defaultRuntimeMode, - interactionMode: DEFAULT_INTERACTION_MODE, + const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; + if (!isServerThread && activeDraftSession?.logicalProjectKey === logicalProjectKey && draftId) { + setDraftThreadContext(draftId, input); + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { + threadId: activeDraftSession.threadId, + createdAt: activeDraftSession.createdAt, + runtimeMode: activeDraftSession.runtimeMode, + interactionMode: activeDraftSession.interactionMode, ...input, }); - await navigate({ - to: "/draft/$draftId", - params: buildDraftThreadRouteParams(nextDraftId), - }); - return nextThreadId; - }, - [ - activeProject, - draftId, - getDraftSession, - getDraftSessionByLogicalProjectKey, - isServerThread, - navigate, - projectGroupingSettings, - routeKind, - settings, - setDraftThreadContext, - setLogicalProjectDraftThreadId, - ], - ); + return activeDraftSession.threadId; + } - const handlePreparedPullRequestThread = useCallback( - async (input: { branch: string; worktreePath: string | null }) => { - await openOrReuseProjectDraftThread({ - branch: input.branch, - worktreePath: input.worktreePath, - envMode: input.worktreePath ? "worktree" : "local", - }); - }, - [openOrReuseProjectDraftThread], - ); + const nextDraftId = newDraftId(); + const nextThreadId = newThreadId(); + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { + threadId: nextThreadId, + createdAt: new Date().toISOString(), + runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings + .defaultRuntimeMode, + interactionMode: DEFAULT_INTERACTION_MODE, + ...input, + }); + await navigate({ + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(nextDraftId), + }); + return nextThreadId; + }; + + const handlePreparedPullRequestThread = async (input: { + branch: string; + worktreePath: string | null; + }) => { + await openOrReuseProjectDraftThread({ + branch: input.branch, + worktreePath: input.worktreePath, + envMode: input.worktreePath ? "worktree" : "local", + }); + }; + const handlePreparedPullRequestThreadStable = useStableCallback(handlePreparedPullRequestThread); useEffect(() => { if (!serverThread?.id) return; @@ -3106,7 +3186,9 @@ export default function ChatView(props: ChatViewProps) { disabled={disconnectingEnvironment} title="Hide this server's threads. Switch it on again in Connections." onClick={() => - void handleDisconnectActiveEnvironment(activeEnvironmentUnavailableState.environmentId) + void handleDisconnectActiveEnvironmentStable( + activeEnvironmentUnavailableState.environmentId, + ) } > Disconnect server @@ -3134,7 +3216,7 @@ export default function ChatView(props: ChatViewProps) { size="xs" variant="ghost" onClick={() => - void handleReconnectActiveEnvironment( + void handleReconnectActiveEnvironmentStable( activeEnvironmentUnavailableState.environmentId, ) } @@ -3233,10 +3315,10 @@ export default function ChatView(props: ChatViewProps) { automaticEnvironment, autoBalanceUpdateBanner, activeEnvironmentUnavailableState, - handleReconnectActiveEnvironment, + handleReconnectActiveEnvironmentStable, canDisconnectActiveEnvironment, disconnectingEnvironment, - handleDisconnectActiveEnvironment, + handleDisconnectActiveEnvironmentStable, setDismissedVersionMismatchKey, showVersionMismatchBanner, reconnectWarningGraceElapsed, @@ -3554,7 +3636,7 @@ export default function ChatView(props: ChatViewProps) { activeProviderStatus !== null && hasProviderUsageLimits(activeProviderStatus.driver, providerStatuses, usageLimitSources); // Answered locally from the last Limits snapshot; the agent never sees it. - const openUsageLimits = useCallback(() => { + const openUsageLimits = () => { const now = Date.now(); const report = activeProviderInstanceId !== null && usageLimitsKey !== null @@ -3572,21 +3654,13 @@ export default function ChatView(props: ChatViewProps) { setUsageLimitsPanel(null); toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); return false; - }, [ - activeProviderInstanceId, - providerStatuses, - routeThreadKey, - usageLimitSources, - usageLimitsKey, - ]); + }; + const openUsageLimitsStable = useStableCallback(openUsageLimits); // Responses can resolve after navigating away; only the originating thread's panel clears. - const clearUsageLimitsFor = useCallback( - (threadKey: string) => - setUsageLimitsPanel((current) => - current !== null && current.threadKey === threadKey ? null : current, - ), - [], - ); + const clearUsageLimitsFor = (threadKey: string) => + setUsageLimitsPanel((current) => + current !== null && current.threadKey === threadKey ? null : current, + ); const { beginLocalDispatch, resetLocalDispatch, @@ -3688,27 +3762,28 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); - const clearAttachmentPreviewHandoff = useCallback( - (messageId: MessageId, previewUrls?: ReadonlyArray) => { - delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; - const currentPreviewUrls = - previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; - setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) { - return existing; - } - const next = { ...existing }; - delete next[messageId]; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; - }); - for (const previewUrl of currentPreviewUrls) { - revokeBlobPreviewUrl(previewUrl); + const clearAttachmentPreviewHandoff = ( + messageId: MessageId, + previewUrls?: ReadonlyArray, + ) => { + delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; + const currentPreviewUrls = + previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; + setAttachmentPreviewHandoffByMessageId((existing) => { + if (!(messageId in existing)) { + return existing; } - }, - [], - ); - const clearAttachmentPreviewHandoffs = useCallback(() => { + const next = { ...existing }; + delete next[messageId]; + attachmentPreviewHandoffByMessageIdRef.current = next; + return next; + }); + for (const previewUrl of currentPreviewUrls) { + revokeBlobPreviewUrl(previewUrl); + } + }; + const clearAttachmentPreviewHandoffStable = useStableCallback(clearAttachmentPreviewHandoff); + const clearAttachmentPreviewHandoffs = () => { attachmentPreviewPromotionInFlightByMessageIdRef.current = {}; for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { for (const previewUrl of previewUrls) { @@ -3717,16 +3792,17 @@ export default function ChatView(props: ChatViewProps) { } attachmentPreviewHandoffByMessageIdRef.current = {}; setAttachmentPreviewHandoffByMessageId({}); - }, []); + }; + const clearAttachmentPreviewHandoffsStable = useStableCallback(clearAttachmentPreviewHandoffs); useEffect(() => { return () => { - clearAttachmentPreviewHandoffs(); + clearAttachmentPreviewHandoffsStable(); for (const message of optimisticUserMessagesRef.current) { revokeUserMessagePreviewUrls(message); } }; - }, [clearAttachmentPreviewHandoffs]); - const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { + }, [clearAttachmentPreviewHandoffsStable]); + const handoffAttachmentPreviews = (messageId: MessageId, previewUrls: string[]) => { if (previewUrls.length === 0) return; const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; @@ -3744,45 +3820,42 @@ export default function ChatView(props: ChatViewProps) { attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); - }, []); - const downloadFileAttachment = useCallback( - async (attachment: ChatFileAttachment) => { - const connection = readPreparedConnection(environmentId); - if (!connection) { - toastManager.add({ type: "error", title: "The environment is not connected." }); - return; - } + }; + const handoffAttachmentPreviewsStable = useStableCallback(handoffAttachmentPreviews); + const downloadFileAttachment = async (attachment: ChatFileAttachment) => { + const connection = readPreparedConnection(environmentId); + if (!connection) { + toastManager.add({ type: "error", title: "The environment is not connected." }); + return; + } - try { - const url = await resolveFileAttachmentUrl({ - attachment, - environmentId, - httpBaseUrl: connection.httpBaseUrl, - createAssetUrl: createAttachmentAssetUrl, - }); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = attachment.name; - anchor.click(); - } catch (error) { - toastManager.add({ - type: "error", - title: "Could not download " + attachment.name, - description: error instanceof Error ? error.message : "The attachment is unavailable.", - }); - } - }, - [createAttachmentAssetUrl, environmentId], - ); - const openFileAttachment = useCallback( - (attachment: ChatFileAttachment) => { - if (activeThreadRef) { - useRightPanelStore.getState().openAttachment(activeThreadRef, attachment); - return; - } - }, - [activeThreadRef], - ); + try { + const url = await resolveFileAttachmentUrl({ + attachment, + environmentId, + httpBaseUrl: connection.httpBaseUrl, + createAssetUrl: createAttachmentAssetUrl, + }); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = attachment.name; + anchor.click(); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not download " + attachment.name, + description: error instanceof Error ? error.message : "The attachment is unavailable.", + }); + } + }; + const downloadFileAttachmentStable = useStableCallback(downloadFileAttachment); + const openFileAttachment = (attachment: ChatFileAttachment) => { + if (activeThreadRef) { + useRightPanelStore.getState().openAttachment(activeThreadRef, attachment); + return; + } + }; + const openFileAttachmentStable = useStableCallback(openFileAttachment); const serverAttachmentResources = useMemo( () => selectHandoffImageResources( @@ -3873,7 +3946,7 @@ export default function ChatView(props: ChatViewProps) { if (cancelled) { return; } - clearAttachmentPreviewHandoff(messageId as MessageId, handoffPreviewUrls); + clearAttachmentPreviewHandoffStable(messageId as MessageId, handoffPreviewUrls); }) .catch(() => { if (!cancelled) { @@ -3897,7 +3970,7 @@ export default function ChatView(props: ChatViewProps) { }; }, [ attachmentPreviewHandoffByMessageId, - clearAttachmentPreviewHandoff, + clearAttachmentPreviewHandoffStable, serverAttachmentUrlById, serverVisibleTurnItems, ]); @@ -4064,25 +4137,24 @@ export default function ChatView(props: ChatViewProps) { () => new Set(retryableRunIdsKey === "" ? [] : (retryableRunIdsKey.split("\n") as RunId[])), [retryableRunIdsKey], ); - const onRetryWorkspacePreparation = useCallback( - (runId: RunId) => { - // One retry per failed run: a second click lands after the run is preparing again. - if (!activeThreadRef || retryingWorkspacePreparationRunIds.has(runId)) return; - retryingWorkspacePreparationRunIds.add(runId); - void retryWorkspacePreparation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, runId }, - }).finally(() => retryingWorkspacePreparationRunIds.delete(runId)); - }, - [activeThreadRef, retryWorkspacePreparation], - ); - const onCancelWorktreeSetup = useCallback(() => { + const onRetryWorkspacePreparation = (runId: RunId) => { + // One retry per failed run: a second click lands after the run is preparing again. + if (!activeThreadRef || retryingWorkspacePreparationRunIds.has(runId)) return; + retryingWorkspacePreparationRunIds.add(runId); + void retryWorkspacePreparation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, runId }, + }).finally(() => retryingWorkspacePreparationRunIds.delete(runId)); + }; + const onRetryWorkspacePreparationStable = useStableCallback(onRetryWorkspacePreparation); + const onCancelWorktreeSetup = () => { if (!worktreeSetup || worktreeSetup.phase !== "running") return; void cancelWorktreeSetup({ environmentId: setupTarget.environmentId, input: { threadId: worktreeSetup.threadId }, }); - }, [cancelWorktreeSetup, setupTarget.environmentId, worktreeSetup]); + }; + const onCancelWorktreeSetupStable = useStableCallback(onCancelWorktreeSetup); // The setup terminal belongs to the thread that was set up. A failed // bootstrap deletes that thread and closes its terminals, so only offer the // terminal while the setup thread is still the active one. @@ -4349,7 +4421,7 @@ export default function ChatView(props: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); - const onToggleDiff = useCallback(() => { + const onToggleDiff = () => { if (!isServerThread) { return; } @@ -4359,7 +4431,8 @@ export default function ChatView(props: ChatViewProps) { if (activeThreadRef) { useRightPanelStore.getState().toggle(activeThreadRef, "diff"); } - }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); + }; + const onToggleDiffStable = useStableCallback(onToggleDiff); const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; const loadBalancingCandidates = useMemo( @@ -4425,7 +4498,7 @@ export default function ChatView(props: ChatViewProps) { logicalProjectEnvironments, setDraftThreadContext, ]); - const onAutoEnvironment = useCallback(() => { + const onAutoEnvironment = () => { if (envLocked || !draftId) return; if (composerHasAttachments) { toastManager.add({ @@ -4446,14 +4519,8 @@ export default function ChatView(props: ChatViewProps) { branch: null, worktreePath: null, }); - }, [ - envLocked, - draftId, - setDraftThreadContext, - loadBalancing.refresh, - logicalProjectEnvironments, - composerHasAttachments, - ]); + }; + const onAutoEnvironmentStable = useStableCallback(onAutoEnvironment); const autoEnvironmentLabel = automaticEnvironment ? draftThread?.loadBalancedEnvironmentId ? "Auto balance" @@ -4474,83 +4541,69 @@ export default function ChatView(props: ChatViewProps) { }; }, [draftId, activeProjectKey]); - const onEnvironmentChange = useCallback( - (nextEnvironmentId: EnvironmentId) => { - if (envLocked || !draftId || sendInFlightRef.current) return; - const originalDraft = getDraftSession(draftId); - if (!originalDraft || originalDraft.promotedTo) return; - const target = logicalProjectEnvironments.find( - (env) => env.environmentId === nextEnvironmentId, - ); - if (!target) return; - const request = { environmentId: target.environmentId }; - environmentChangeRef.current = request; - setIsEnvironmentChanging(false); - const retarget = (project: (typeof allProjects)[number]) => { - const currentDraft = getDraftSession(draftId); - if ( - environmentChangeRef.current !== request || - sendInFlightRef.current || - !currentDraft || - currentDraft.promotedTo || - currentDraft.environmentId !== originalDraft.environmentId || - currentDraft.projectId !== originalDraft.projectId - ) - return; - const projectRef = scopeProjectRef(target.environmentId, project.id); - if (activeProjectIsScratch) { - // Scratch projects are machine-local, so move their logical mapping too. - setLogicalProjectDraftThreadId( - deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), - projectRef, - draftId, - { environmentSelection: "manual", loadBalancedEnvironmentId: null }, - ); - } else { - setDraftThreadContext(draftId, { - projectRef, - environmentSelection: "manual", - loadBalancedEnvironmentId: null, - }); - } - }; - const finish = () => { - if (environmentChangeRef.current === request) { - environmentChangeRef.current = null; - setIsEnvironmentChanging(false); - } - }; - if (target.projectId !== null) { - const project = allProjects.find( - (project) => - project.environmentId === target.environmentId && project.id === target.projectId, - ); - if (project) retarget(project); - finish(); + const onEnvironmentChange = (nextEnvironmentId: EnvironmentId) => { + if (envLocked || !draftId || sendInFlightRef.current) return; + const originalDraft = getDraftSession(draftId); + if (!originalDraft || originalDraft.promotedTo) return; + const target = logicalProjectEnvironments.find( + (env) => env.environmentId === nextEnvironmentId, + ); + if (!target) return; + const request = { environmentId: target.environmentId }; + environmentChangeRef.current = request; + setIsEnvironmentChanging(false); + const retarget = (project: (typeof allProjects)[number]) => { + const currentDraft = getDraftSession(draftId); + if ( + environmentChangeRef.current !== request || + sendInFlightRef.current || + !currentDraft || + currentDraft.promotedTo || + currentDraft.environmentId !== originalDraft.environmentId || + currentDraft.projectId !== originalDraft.projectId + ) return; + const projectRef = scopeProjectRef(target.environmentId, project.id); + if (activeProjectIsScratch) { + // Scratch projects are machine-local, so move their logical mapping too. + setLogicalProjectDraftThreadId( + deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), + projectRef, + draftId, + { environmentSelection: "manual", loadBalancedEnvironmentId: null }, + ); + } else { + setDraftThreadContext(draftId, { + projectRef, + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); } - // Keep send disabled until the destination Scratch project is ready. - setIsEnvironmentChanging(true); - void openScratchProject(target.environmentId, "Could not switch machine") - .then((project) => { - if (project) retarget(project); - }) - .finally(finish); - }, - [ - activeProjectIsScratch, - allProjects, - draftId, - envLocked, - getDraftSession, - logicalProjectEnvironments, - openScratchProject, - projectGroupingSettings, - sendInFlightRef, - setDraftThreadContext, - setLogicalProjectDraftThreadId, - ], - ); + }; + const finish = () => { + if (environmentChangeRef.current === request) { + environmentChangeRef.current = null; + setIsEnvironmentChanging(false); + } + }; + if (target.projectId !== null) { + const project = allProjects.find( + (project) => + project.environmentId === target.environmentId && project.id === target.projectId, + ); + if (project) retarget(project); + finish(); + return; + } + // Keep send disabled until the destination Scratch project is ready. + setIsEnvironmentChanging(true); + void openScratchProject(target.environmentId, "Could not switch machine") + .then((project) => { + if (project) retarget(project); + }) + .finally(finish); + }; + const onEnvironmentChangeStable = useStableCallback(onEnvironmentChange); const activeTerminalGroup = terminalUiState.terminalGroups.find( @@ -4562,48 +4615,28 @@ export default function ChatView(props: ChatViewProps) { null; const hasReachedSplitLimit = (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; - const setThreadError = useCallback( - (targetThreadId: ThreadId | null, error: string | null) => { - if (!targetThreadId) return; - const nextError = sanitizeThreadErrorMessage(error); - const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; - if ( - serverThread && - targetThreadId === routeThreadRef.threadId && - serverThread.environmentId === routeThreadRef.environmentId && - serverThread.id === targetThreadId - ) { - setLocalServerErrorsByThreadKey((existing) => { - if ((existing[routeThreadKey]?.message ?? null) === nextError) { - return existing; - } - return { - ...existing, - [routeThreadKey]: nextEntry, - }; - }); - return; - } - const localDraftErrorKey = draftId ?? targetThreadId; - setLocalDraftErrorsByDraftId((existing) => { - if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { - return existing; - } - return { - ...existing, - [localDraftErrorKey]: nextEntry, - }; - }); - }, - [draftId, routeThreadKey, routeThreadRef, serverThread], + const setThreadError = useMemo( + () => + makeThreadErrorSetter({ + routeThreadRef, + routeThreadKey, + draftId, + serverThreadRef: serverThread + ? { threadId: serverThread.id, environmentId: serverThread.environmentId } + : null, + setLocalServerErrorsByThreadKey, + setLocalDraftErrorsByDraftId, + }), + [draftId, routeThreadKey, routeThreadRef, serverThread?.id, serverThread?.environmentId], ); - const focusComposer = useCallback(() => { + const focusComposer = () => { composerRef.current?.focusAtEnd(); - }, [composerRef]); + }; + const focusComposerStable = useStableCallback(focusComposer); const canInterruptRunningThread = canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime); - const onInterrupt = useCallback(async () => { + const onInterrupt = async () => { if ( !activeThread || !readEnvironmentScope(activeThread.environmentId, AuthOrchestrationOperateScope) @@ -4620,56 +4653,54 @@ export default function ChatView(props: ChatViewProps) { error instanceof Error ? error.message : "Failed to interrupt the current turn.", ); } - }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); - useEffect(() => subscribeSnapShotComposerFocus(focusComposer), [focusComposer]); - const scheduleComposerFocus = useCallback(() => { + }; + const onInterruptStable = useStableCallback(onInterrupt); + useEffect(() => subscribeSnapShotComposerFocus(focusComposerStable), [focusComposerStable]); + const scheduleComposerFocus = () => { window.requestAnimationFrame(() => { focusComposer(); }); - }, [focusComposer]); - const useArtifactTemplate = useCallback( - (template: CodexArtifactTemplate) => { - const composer = composerRef.current; - if (!composer) return; - - const currentDraft = composer.getSendContext().prompt; - const prompt = codexArtifactTemplatePromptToAppend(currentDraft, template); - if (prompt !== null && !composer.insertTextAtEnd(prompt, { ensureLeadingBoundary: true })) { - toastManager.add({ - type: "error", - title: "Unable to add to chat", - description: "The composer is busy; try again once it is ready.", - }); - return; - } - scheduleComposerFocus(); - }, - [composerRef, scheduleComposerFocus], - ); + }; + const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus); + const useArtifactTemplate = (template: CodexArtifactTemplate) => { + const composer = composerRef.current; + if (!composer) return; + + const currentDraft = composer.getSendContext().prompt; + const prompt = codexArtifactTemplatePromptToAppend(currentDraft, template); + if (prompt !== null && !composer.insertTextAtEnd(prompt, { ensureLeadingBoundary: true })) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The composer is busy; try again once it is ready.", + }); + return; + } + scheduleComposerFocus(); + }; + const useArtifactTemplateStable = useStableCallback(useArtifactTemplate); // An MCP App's approved `ui/message`: queued like a typed message, so it // never steers or interrupts a running turn. - const sendAppMessage = useCallback( - async (text: string) => { - if (!isServerThread || activeThreadId === null) { - throw new Error("Messages from apps need a started thread."); - } - const result = await startThreadTurn({ - environmentId, - input: { - threadId: activeThreadId, - message: { messageId: newMessageId(), role: "user", text, attachments: [] }, - runtimeMode, - interactionMode, - dispatchMode: "queue", - }, - }); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - throw error instanceof Error ? error : new Error("Could not send the app's message."); - } - }, - [activeThreadId, environmentId, interactionMode, isServerThread, runtimeMode, startThreadTurn], - ); + const sendAppMessage = async (text: string) => { + if (!isServerThread || activeThreadId === null) { + throw new Error("Messages from apps need a started thread."); + } + const result = await startThreadTurn({ + environmentId, + input: { + threadId: activeThreadId, + message: { messageId: newMessageId(), role: "user", text, attachments: [] }, + runtimeMode, + interactionMode, + dispatchMode: "queue", + }, + }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + throw error instanceof Error ? error : new Error("Could not send the app's message."); + } + }; + const sendAppMessageStable = useStableCallback(sendAppMessage); const editQueuedRunCommand = useAtomCommand(threadEnvironment.editQueuedRun, { reportFailure: false, }); @@ -4697,48 +4728,34 @@ export default function ChatView(props: ChatViewProps) { url: urlByAttachmentId.get(attachment.id) ?? null, })); }, [editingQueuedRun, queuedEditImageResources, queuedEditImageUrls]); - const beginEditingQueuedRun = useCallback( - (request: EditQueuedRunRequest) => { - if (!activeThread) return; - if (editingQueuedRun !== null && editingQueuedRun.runId !== request.runId) { - clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId)); - } - const target = queuedEditDraftTargetFor(request.runId); - clearComposerDraftContent(target); - setComposerDraftPrompt(target, request.text); - setEditingQueuedRun({ - threadId: activeThread.id, - runId: request.runId, - messageId: request.messageId, - originalText: request.text, - existingAttachments: request.attachments, - context: serverProjection?.messages.find((message) => message.id === request.messageId) - ?.context, - }); - scheduleComposerFocus(); - }, - [ - activeThread, - clearComposerDraftContent, - editingQueuedRun, - queuedEditDraftTargetFor, - serverProjection, - scheduleComposerFocus, - setComposerDraftPrompt, - ], - ); - const cancelEditingQueuedRun = useCallback(() => { + const beginEditingQueuedRun = (request: EditQueuedRunRequest) => { + if (!activeThread) return; + if (editingQueuedRun !== null && editingQueuedRun.runId !== request.runId) { + clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId)); + } + const target = queuedEditDraftTargetFor(request.runId); + clearComposerDraftContent(target); + setComposerDraftPrompt(target, request.text); + setEditingQueuedRun({ + threadId: activeThread.id, + runId: request.runId, + messageId: request.messageId, + originalText: request.text, + existingAttachments: request.attachments, + context: serverProjection?.messages.find((message) => message.id === request.messageId) + ?.context, + }); + scheduleComposerFocus(); + }; + const beginEditingQueuedRunStable = useStableCallback(beginEditingQueuedRun); + const cancelEditingQueuedRun = () => { if (editingQueuedRun === null) return; clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId)); setEditingQueuedRun(null); scheduleComposerFocus(); - }, [ - clearComposerDraftContent, - editingQueuedRun, - queuedEditDraftTargetFor, - scheduleComposerFocus, - ]); - const removeEditingQueuedAttachment = useCallback((attachmentId: string) => { + }; + const cancelEditingQueuedRunStable = useStableCallback(cancelEditingQueuedRun); + const removeEditingQueuedAttachment = (attachmentId: string) => { setEditingQueuedRun((current) => current === null ? current @@ -4749,7 +4766,8 @@ export default function ChatView(props: ChatViewProps) { ), }, ); - }, []); + }; + const removeEditingQueuedAttachmentStable = useStableCallback(removeEditingQueuedAttachment); // Exit edit mode when the edited run leaves the queue (it started, or was // cancelled from another client). A dirty edit moves into the thread's own // draft when that draft is empty; otherwise it is dropped with a toast. @@ -4792,20 +4810,16 @@ export default function ChatView(props: ChatViewProps) { queuedEditDraftTargetFor, serverProjection, ]); - const addTerminalContextToDraft = useCallback( - (selection: TerminalContextSelection) => { - composerRef.current?.addTerminalContext(selection); - }, - [composerRef], - ); - const setTerminalOpen = useCallback( - (open: boolean) => { - if (!activeThreadRef) return; - storeSetTerminalOpen(activeThreadRef, open); - }, - [activeThreadRef, storeSetTerminalOpen], - ); - const toggleTerminalVisibility = useCallback(() => { + const addTerminalContextToDraft = (selection: TerminalContextSelection) => { + composerRef.current?.addTerminalContext(selection); + }; + const addTerminalContextToDraftStable = useStableCallback(addTerminalContextToDraft); + const setTerminalOpen = (open: boolean) => { + if (!activeThreadRef) return; + storeSetTerminalOpen(activeThreadRef, open); + }; + const setTerminalOpenStable = useStableCallback(setTerminalOpen); + const toggleTerminalVisibility = () => { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; if ( @@ -4849,76 +4863,45 @@ export default function ChatView(props: ChatViewProps) { return; } setTerminalOpen(nextOpen); - }, [ - activeServerOrderedTerminalIds, - activeProject, - activeThreadId, - activeThreadRef, - activeThreadWorktreePath, - allocateTerminalId, - environmentId, - gitCwd, - openTerminal, - panelTerminalIds, - runningTerminalIds, - hasTerminalWriteAccess, - setTerminalOpen, - storeEnsureTerminal, - terminalUiState.terminalIds.length, - terminalUiState.terminalOpen, - ]); - const splitTerminal = useCallback( - (direction: "horizontal" | "vertical" = "horizontal") => { - if ( - !hasTerminalWriteAccess() || - !activeThreadRef || - hasReachedSplitLimit || - !activeThreadId || - !activeProject - ) { - return; - } - const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; - if (!cwdForOpen) { - return; - } - const terminalId = allocateTerminalId(); - if (direction === "vertical") { - storeSplitTerminalVertical(activeThreadRef, terminalId); - } else { - storeSplitTerminal(activeThreadRef, terminalId); - } - setTerminalFocusRequestId((value) => value + 1); - void openTerminal({ - environmentId, - input: { - threadId: activeThreadId, - terminalId, - cwd: cwdForOpen, - ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), - env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.workspaceRoot }, - worktreePath: activeThreadWorktreePath, - }), - }, - }); - }, - [ - activeProject, - activeThreadId, - allocateTerminalId, - activeThreadRef, - openTerminal, - hasTerminalWriteAccess, - activeThreadWorktreePath, + }; + const toggleTerminalVisibilityStable = useStableCallback(toggleTerminalVisibility); + const splitTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { + if ( + !hasTerminalWriteAccess() || + !activeThreadRef || + hasReachedSplitLimit || + !activeThreadId || + !activeProject + ) { + return; + } + const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; + if (!cwdForOpen) { + return; + } + const terminalId = allocateTerminalId(); + if (direction === "vertical") { + storeSplitTerminalVertical(activeThreadRef, terminalId); + } else { + storeSplitTerminal(activeThreadRef, terminalId); + } + setTerminalFocusRequestId((value) => value + 1); + void openTerminal({ environmentId, - gitCwd, - hasReachedSplitLimit, - storeSplitTerminal, - storeSplitTerminalVertical, - ], - ); - const createNewTerminal = useCallback(() => { + input: { + threadId: activeThreadId, + terminalId, + cwd: cwdForOpen, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.workspaceRoot }, + worktreePath: activeThreadWorktreePath, + }), + }, + }); + }; + const splitTerminalStable = useStableCallback(splitTerminal); + const createNewTerminal = () => { if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) { return; } @@ -4942,206 +4925,162 @@ export default function ChatView(props: ChatViewProps) { }), }, }); - }, [ - activeProject, - activeThreadId, - allocateTerminalId, - activeThreadRef, - openTerminal, - hasTerminalWriteAccess, - activeThreadWorktreePath, - environmentId, - gitCwd, - storeNewTerminal, - ]); - const closeTerminal = useCallback( - (terminalId: string) => { - if (!hasTerminalWriteAccess() || !activeThreadId || !activeThreadRef) return; - const fallbackExitWrite = () => - writeTerminal({ - environmentId, - input: { threadId: activeThreadId, terminalId, data: "exit\n" }, - }); - void (async () => { - const closeResult = await closeTerminalMutation({ - environmentId, - input: { - threadId: activeThreadId, - terminalId, - deleteHistory: true, - }, - }); - if ( - closeResult._tag === "Failure" && - !isAtomCommandInterrupted(closeResult) && - hasTerminalWriteAccess() - ) { - await fallbackExitWrite(); - } - })(); - storeCloseTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, - [ - activeThreadId, - activeThreadRef, - closeTerminalMutation, - hasTerminalWriteAccess, - environmentId, - storeCloseTerminal, - writeTerminal, - ], - ); - const runProjectScript = useCallback( - async ( - script: ProjectScript, - options?: { - cwd?: string; - env?: Record; - worktreePath?: string | null; - preferNewTerminal?: boolean; - rememberAsLastInvoked?: boolean; - }, - ) => { - if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return; - if (options?.rememberAsLastInvoked !== false) { - setLastInvokedScriptByProjectId((current) => { - if (current[activeProject.id] === script.id) return current; - return { ...current, [activeProject.id]: script.id }; - }); - } - const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; - const baseTerminalId = - terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; - const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); - const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; - const shouldCreateNewTerminal = - wantsNewTerminal || - !canReuseTerminal || - !readEnvironmentScope(environmentId, AuthTerminalReadScope); - const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; - - setTerminalUiLaunchContext({ - threadId: activeThreadId, - cwd: targetCwd, - worktreePath: targetWorktreePath, - }); - setTerminalOpen(true); - if (!activeThreadRef) { - return; - } - setTerminalFocusRequestId((value) => value + 1); - - const runtimeEnv = projectScriptRuntimeEnv({ - project: { - cwd: activeProject.workspaceRoot, - }, - worktreePath: targetWorktreePath, - ...(options?.env ? { extraEnv: options.env } : {}), + }; + const createNewTerminalStable = useStableCallback(createNewTerminal); + const closeTerminal = (terminalId: string) => { + if (!hasTerminalWriteAccess() || !activeThreadId || !activeThreadRef) return; + const fallbackExitWrite = () => + writeTerminal({ + environmentId, + input: { threadId: activeThreadId, terminalId, data: "exit\n" }, }); - const targetTerminalId = shouldCreateNewTerminal ? allocateTerminalId() : baseTerminalId; - const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal - ? { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, - cols: SCRIPT_TERMINAL_COLS, - rows: SCRIPT_TERMINAL_ROWS, - } - : { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, - }; - - if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadRef, targetTerminalId); - } else { - storeSetActiveTerminal(activeThreadRef, targetTerminalId); - } - - const openResult = await openTerminal({ environmentId, input: openTerminalInput }); - if (openResult._tag === "Failure") { - if (!isAtomCommandInterrupted(openResult)) { - const error = squashAtomCommandFailure(openResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } - return; - } - - if (!hasTerminalWriteAccess()) return; - const writeResult = await writeTerminal({ + void (async () => { + const closeResult = await closeTerminalMutation({ environmentId, input: { threadId: activeThreadId, - terminalId: targetTerminalId, - data: `${script.command}\r`, + terminalId, + deleteHistory: true, }, }); - if (writeResult._tag === "Failure") { - if (!isAtomCommandInterrupted(writeResult)) { - const error = squashAtomCommandFailure(writeResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } - return; + if ( + closeResult._tag === "Failure" && + !isAtomCommandInterrupted(closeResult) && + hasTerminalWriteAccess() + ) { + await fallbackExitWrite(); } - if (script.autoOpenPreview && script.previewUrl && isPreviewSupportedInRuntime()) { - const previewResult = await openUrlInPreview({ - threadRef: activeThreadRef, - url: resolveDiscoveredServerUrl(activeThreadRef.environmentId, script.previewUrl), - openPreview, - }); - if (previewResult._tag === "Failure" && !isAtomCommandInterrupted(previewResult)) { - const error = squashAtomCommandFailure(previewResult); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not open preview", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); + })(); + storeCloseTerminal(activeThreadRef, terminalId); + setTerminalFocusRequestId((value) => value + 1); + }; + const runProjectScript = async ( + script: ProjectScript, + options?: { + cwd?: string; + env?: Record; + worktreePath?: string | null; + preferNewTerminal?: boolean; + rememberAsLastInvoked?: boolean; + }, + ) => { + if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return; + if (options?.rememberAsLastInvoked !== false) { + setLastInvokedScriptByProjectId((current) => { + if (current[activeProject.id] === script.id) return current; + return { ...current, [activeProject.id]: script.id }; + }); + } + const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; + const baseTerminalId = + terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; + const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); + const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; + const shouldCreateNewTerminal = + wantsNewTerminal || + !canReuseTerminal || + !readEnvironmentScope(environmentId, AuthTerminalReadScope); + const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; + + setTerminalUiLaunchContext({ + threadId: activeThreadId, + cwd: targetCwd, + worktreePath: targetWorktreePath, + }); + setTerminalOpen(true); + if (!activeThreadRef) { + return; + } + setTerminalFocusRequestId((value) => value + 1); + + const runtimeEnv = projectScriptRuntimeEnv({ + project: { + cwd: activeProject.workspaceRoot, + }, + worktreePath: targetWorktreePath, + ...(options?.env ? { extraEnv: options.env } : {}), + }); + const targetTerminalId = shouldCreateNewTerminal ? allocateTerminalId() : baseTerminalId; + const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal + ? { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), + env: runtimeEnv, + cols: SCRIPT_TERMINAL_COLS, + rows: SCRIPT_TERMINAL_ROWS, } + : { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), + env: runtimeEnv, + }; + + if (shouldCreateNewTerminal) { + storeNewTerminal(activeThreadRef, targetTerminalId); + } else { + storeSetActiveTerminal(activeThreadRef, targetTerminalId); + } + + const openResult = await openTerminal({ environmentId, input: openTerminalInput }); + if (openResult._tag === "Failure") { + if (!isAtomCommandInterrupted(openResult)) { + const error = squashAtomCommandFailure(openResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + ); } - }, - [ - activeProject, - activeThread, - activeThreadId, - activeThreadRef, - gitCwd, - setTerminalOpen, - setThreadError, - storeNewTerminal, - storeSetActiveTerminal, - setLastInvokedScriptByProjectId, + return; + } + + if (!hasTerminalWriteAccess()) return; + const writeResult = await writeTerminal({ environmentId, - openTerminal, - openPreview, - hasTerminalWriteAccess, - activeKnownTerminalIds, - canReuseTerminal, - allocateTerminalId, - runningTerminalIds, - terminalUiState.activeTerminalId, - writeTerminal, - ], - ); + input: { + threadId: activeThreadId, + terminalId: targetTerminalId, + data: `${script.command}\r`, + }, + }); + if (writeResult._tag === "Failure") { + if (!isAtomCommandInterrupted(writeResult)) { + const error = squashAtomCommandFailure(writeResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + ); + } + return; + } + if (script.autoOpenPreview && script.previewUrl && isPreviewSupportedInRuntime()) { + const previewResult = await openUrlInPreview({ + threadRef: activeThreadRef, + url: resolveDiscoveredServerUrl(activeThreadRef.environmentId, script.previewUrl), + openPreview, + }); + if (previewResult._tag === "Failure" && !isAtomCommandInterrupted(previewResult)) { + const error = squashAtomCommandFailure(previewResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not open preview", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + } + } + }; + const runProjectScriptStable = useStableCallback(runProjectScript); - const runProjectScriptRef = useRef(runProjectScript); + const runProjectScriptRef = useRef(runProjectScriptStable); useLayoutEffect(() => { - runProjectScriptRef.current = runProjectScript; - }, [runProjectScript]); - const runShellCommand = useCallback((command: string) => { + runProjectScriptRef.current = runProjectScriptStable; + }, [runProjectScriptStable]); + const runShellCommand = (command: string) => { void runProjectScriptRef.current( { id: "chat-code-block", @@ -5152,334 +5091,302 @@ export default function ChatView(props: ChatViewProps) { }, { rememberAsLastInvoked: false }, ); - }, []); + }; + const runShellCommandStable = useStableCallback(runShellCommand); const supportsProjectSettingsOverrides = environmentById.get(environmentId)?.serverConfig?.environment.capabilities .projectSettingsOverrides === true; - const persistProjectScripts = useCallback( - async (input: { - projectId: ProjectId; - projectCwd: string; - previousScripts: ReadonlyArray; - nextScripts: ReadonlyArray; - keybinding: NewProjectScriptInput["keybinding"]; - keybindingCommand: KeybindingCommand | null; - }): Promise> => { - const previousKeybinding = keybindingValueForCommand( - appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? [], - input.keybindingCommand, - ); - const isDeletingScript = !input.nextScripts.some( - (script) => commandForProjectScript(script.id) === input.keybindingCommand, - ); - const changesKeybinding = - input.keybinding !== undefined && - (input.keybinding?.trim() || null) !== previousKeybinding && - (!isDeletingScript || readEnvironmentScope(environmentId, AuthSettingsWriteScope)); - if (changesKeybinding && !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { - return AsyncResult.failure( - Cause.fail( - new EnvironmentAuthorizationError({ - requiredScope: AuthSettingsWriteScope, - message: "This connection cannot change keyboard shortcuts.", - }), - ), - ); - } - const keybindingRule = changesKeybinding - ? decodeProjectScriptKeybindingRule({ - keybinding: input.keybinding, - command: input.keybindingCommand, - }) - : null; - const updateResult = mapAtomCommandResult( - await updateProjectScriptSettings({ - environmentId, - input: { - // The canonical key on servers that understand it; the legacy - // per-project map is still translated on older ones. - patch: supportsProjectSettingsOverrides - ? { - projectSettingsOverrides: { - [input.projectId]: { - ...settings.projectSettingsOverrides[input.projectId], - defaultProjectScripts: input.nextScripts, - }, - }, - } - : { - projectScriptOverrides: { - [input.projectId]: input.nextScripts, - }, - }, - }, - }), - () => undefined, + const persistProjectScripts = async (input: { + projectId: ProjectId; + projectCwd: string; + previousScripts: ReadonlyArray; + nextScripts: ReadonlyArray; + keybinding: NewProjectScriptInput["keybinding"]; + keybindingCommand: KeybindingCommand | null; + }): Promise> => { + const previousKeybinding = keybindingValueForCommand( + appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? [], + input.keybindingCommand, + ); + const isDeletingScript = !input.nextScripts.some( + (script) => commandForProjectScript(script.id) === input.keybindingCommand, + ); + const changesKeybinding = + input.keybinding !== undefined && + (input.keybinding?.trim() || null) !== previousKeybinding && + (!isDeletingScript || readEnvironmentScope(environmentId, AuthSettingsWriteScope)); + if (changesKeybinding && !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { + return AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + requiredScope: AuthSettingsWriteScope, + message: "This connection cannot change keyboard shortcuts.", + }), + ), ); - if (updateResult._tag === "Failure") { - return updateResult; - } - - if (!changesKeybinding) return updateResult; - if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { - return AsyncResult.failure( - Cause.fail( - new EnvironmentAuthorizationError({ - requiredScope: AuthSettingsWriteScope, - message: isDeletingScript - ? "The script was deleted, but its keyboard shortcut could not be removed because permission changed." - : "The script was saved, but this connection can no longer change keyboard shortcuts.", - }), - ), - ); - } - - const scriptId = input.keybindingCommand - ? projectScriptIdFromCommand(input.keybindingCommand) - : null; - if (!keybindingRule && !input.previousScripts.some((script) => script.id === scriptId)) { - return updateResult; - } - const retainedElsewhere = - !input.nextScripts.some((script) => script.id === scriptId) && - (settings.defaultProjectScripts.some((script) => script.id === scriptId) || - Object.entries(settings.projectSettingsOverrides).some( - ([projectId, entry]) => - projectId !== input.projectId && - entry.defaultProjectScripts?.some((script) => script.id === scriptId), - ) || - allProjects.some( - (other) => - other.environmentId === environmentId && - other.id !== input.projectId && - resolveProjectScripts(settings, other).some((script) => script.id === scriptId), - )); - if (!keybindingRule && retainedElsewhere) return updateResult; - - const previousRules = ( - environmentById.get(environmentId)?.serverConfig?.keybindings ?? [] - ).flatMap((binding) => { - if (binding.command !== input.keybindingCommand || binding.whenAst) return []; - const previous = decodeProjectScriptKeybindingRule({ - keybinding: keybindingValueForCommand([binding], input.keybindingCommand), + } + const keybindingRule = changesKeybinding + ? decodeProjectScriptKeybindingRule({ + keybinding: input.keybinding, command: input.keybindingCommand, - }); - return previous ? [previous] : []; - }); - const previous = previousRules.at(-1); - for (const rule of keybindingRule ? previousRules.slice(0, -1) : previousRules) { - const result = await removeKeybinding({ environmentId, input: rule }); - if (result._tag === "Failure") return mapAtomCommandResult(result, () => undefined); - } - return keybindingRule - ? mapAtomCommandResult( - await upsertKeybinding({ - environmentId, - input: - previous && previous.key !== keybindingRule.key - ? { ...keybindingRule, replace: previous } - : keybindingRule, - }), - () => undefined, - ) - : updateResult; - }, - [ - allProjects, - environmentById, - environmentId, - removeKeybinding, - settings, - supportsProjectSettingsOverrides, - updateProjectScriptSettings, - upsertKeybinding, - ], - ); - const saveProjectScript = useCallback( - async (input: NewProjectScriptInput): Promise> => { - if (!activeProject) { - return AsyncResult.success(undefined); - } - const nextId = nextProjectScriptId( - input.name, - activeProjectScripts.map((script) => script.id), + }) + : null; + const updateResult = mapAtomCommandResult( + await updateProjectScriptSettings({ + environmentId, + input: { + // The canonical key on servers that understand it; the legacy + // per-project map is still translated on older ones. + patch: supportsProjectSettingsOverrides + ? { + projectSettingsOverrides: { + [input.projectId]: { + ...settings.projectSettingsOverrides[input.projectId], + defaultProjectScripts: input.nextScripts, + }, + }, + } + : { + projectScriptOverrides: { + [input.projectId]: input.nextScripts, + }, + }, + }, + }), + () => undefined, + ); + if (updateResult._tag === "Failure") { + return updateResult; + } + + if (!changesKeybinding) return updateResult; + if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) { + return AsyncResult.failure( + Cause.fail( + new EnvironmentAuthorizationError({ + requiredScope: AuthSettingsWriteScope, + message: isDeletingScript + ? "The script was deleted, but its keyboard shortcut could not be removed because permission changed." + : "The script was saved, but this connection can no longer change keyboard shortcuts.", + }), + ), ); - const nextScript = buildProjectScript(nextId, input); - const nextScripts = [ - ...activeProjectScripts.map((script) => releaseClaimedRoles(script, input)), - nextScript, - ]; + } - return persistProjectScripts({ - projectId: activeProject.id, - projectCwd: activeProject.workspaceRoot, - previousScripts: activeProjectScripts, - nextScripts, - keybinding: input.keybinding, - keybindingCommand: commandForProjectScript(nextId), + const scriptId = input.keybindingCommand + ? projectScriptIdFromCommand(input.keybindingCommand) + : null; + if (!keybindingRule && !input.previousScripts.some((script) => script.id === scriptId)) { + return updateResult; + } + const retainedElsewhere = + !input.nextScripts.some((script) => script.id === scriptId) && + (settings.defaultProjectScripts.some((script) => script.id === scriptId) || + Object.entries(settings.projectSettingsOverrides).some( + ([projectId, entry]) => + projectId !== input.projectId && + entry.defaultProjectScripts?.some((script) => script.id === scriptId), + ) || + allProjects.some( + (other) => + other.environmentId === environmentId && + other.id !== input.projectId && + resolveProjectScripts(settings, other).some((script) => script.id === scriptId), + )); + if (!keybindingRule && retainedElsewhere) return updateResult; + + const previousRules = ( + environmentById.get(environmentId)?.serverConfig?.keybindings ?? [] + ).flatMap((binding) => { + if (binding.command !== input.keybindingCommand || binding.whenAst) return []; + const previous = decodeProjectScriptKeybindingRule({ + keybinding: keybindingValueForCommand([binding], input.keybindingCommand), + command: input.keybindingCommand, }); - }, - [activeProject, activeProjectScripts, persistProjectScripts], - ); - const updateProjectScript = useCallback( - async ( - scriptId: string, - input: NewProjectScriptInput, - ): Promise> => { - if (!activeProject) { - return AsyncResult.success(undefined); - } - const existingScript = activeProjectScripts.find((script) => script.id === scriptId); - if (!existingScript) { - return AsyncResult.failure(Cause.fail(new Error("Script not found."))); - } + return previous ? [previous] : []; + }); + const previous = previousRules.at(-1); + for (const rule of keybindingRule ? previousRules.slice(0, -1) : previousRules) { + const result = await removeKeybinding({ environmentId, input: rule }); + if (result._tag === "Failure") return mapAtomCommandResult(result, () => undefined); + } + return keybindingRule + ? mapAtomCommandResult( + await upsertKeybinding({ + environmentId, + input: + previous && previous.key !== keybindingRule.key + ? { ...keybindingRule, replace: previous } + : keybindingRule, + }), + () => undefined, + ) + : updateResult; + }; + const saveProjectScript = async ( + input: NewProjectScriptInput, + ): Promise> => { + if (!activeProject) { + return AsyncResult.success(undefined); + } + const nextId = nextProjectScriptId( + input.name, + activeProjectScripts.map((script) => script.id), + ); + const nextScript = buildProjectScript(nextId, input); + const nextScripts = [ + ...activeProjectScripts.map((script) => releaseClaimedRoles(script, input)), + nextScript, + ]; - const updatedScript = buildProjectScript(existingScript.id, input); - const nextScripts = activeProjectScripts.map((script) => - script.id === scriptId ? updatedScript : releaseClaimedRoles(script, input), - ); + return persistProjectScripts({ + projectId: activeProject.id, + projectCwd: activeProject.workspaceRoot, + previousScripts: activeProjectScripts, + nextScripts, + keybinding: input.keybinding, + keybindingCommand: commandForProjectScript(nextId), + }); + }; + const saveProjectScriptStable = useStableCallback(saveProjectScript); + const updateProjectScript = async ( + scriptId: string, + input: NewProjectScriptInput, + ): Promise> => { + if (!activeProject) { + return AsyncResult.success(undefined); + } + const existingScript = activeProjectScripts.find((script) => script.id === scriptId); + if (!existingScript) { + return AsyncResult.failure(Cause.fail(new Error("Script not found."))); + } - return persistProjectScripts({ - projectId: activeProject.id, - projectCwd: activeProject.workspaceRoot, - previousScripts: activeProjectScripts, - nextScripts, - keybinding: input.keybinding, - keybindingCommand: commandForProjectScript(scriptId), - }); - }, - [activeProject, activeProjectScripts, persistProjectScripts], - ); - const deleteProjectScript = useCallback( - async (scriptId: string): Promise> => { - if (!activeProject) { - return AsyncResult.success(undefined); - } - const nextScripts = activeProjectScripts.filter((script) => script.id !== scriptId); + const updatedScript = buildProjectScript(existingScript.id, input); + const nextScripts = activeProjectScripts.map((script) => + script.id === scriptId ? updatedScript : releaseClaimedRoles(script, input), + ); + + return persistProjectScripts({ + projectId: activeProject.id, + projectCwd: activeProject.workspaceRoot, + previousScripts: activeProjectScripts, + nextScripts, + keybinding: input.keybinding, + keybindingCommand: commandForProjectScript(scriptId), + }); + }; + const updateProjectScriptStable = useStableCallback(updateProjectScript); + const deleteProjectScript = async ( + scriptId: string, + ): Promise> => { + if (!activeProject) { + return AsyncResult.success(undefined); + } + const nextScripts = activeProjectScripts.filter((script) => script.id !== scriptId); - const deletedName = activeProjectScripts.find((s) => s.id === scriptId)?.name; + const deletedName = activeProjectScripts.find((s) => s.id === scriptId)?.name; - const result = await persistProjectScripts({ - projectId: activeProject.id, - projectCwd: activeProject.workspaceRoot, - previousScripts: activeProjectScripts, - nextScripts, - keybinding: null, - keybindingCommand: commandForProjectScript(scriptId), + const result = await persistProjectScripts({ + projectId: activeProject.id, + projectCwd: activeProject.workspaceRoot, + previousScripts: activeProjectScripts, + nextScripts, + keybinding: null, + keybindingCommand: commandForProjectScript(scriptId), + }); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: `Deleted action "${deletedName ?? "Unknown"}"`, }); - if (result._tag === "Success") { - toastManager.add({ - type: "success", - title: `Deleted action "${deletedName ?? "Unknown"}"`, - }); - } else if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not delete action", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - } - return result; - }, - [activeProject, activeProjectScripts, persistProjectScripts], - ); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not delete action", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }), + ); + } + return result; + }; + const deleteProjectScriptStable = useStableCallback(deleteProjectScript); - const handleRuntimeModeChange = useCallback( - (mode: RuntimeMode) => { - if (mode === runtimeMode) return; - setComposerDraftRuntimeMode(composerDraftTarget, mode); - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); - } - scheduleComposerFocus(); - }, - [ - isLocalDraftThread, - runtimeMode, - scheduleComposerFocus, - composerDraftTarget, - setComposerDraftRuntimeMode, - setDraftThreadContext, - ], - ); + const handleRuntimeModeChange = (mode: RuntimeMode) => { + if (mode === runtimeMode) return; + setComposerDraftRuntimeMode(composerDraftTarget, mode); + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); + } + scheduleComposerFocus(); + }; + const handleRuntimeModeChangeStable = useStableCallback(handleRuntimeModeChange); - const handleInteractionModeChange = useCallback( - (mode: ProviderInteractionMode) => { - if (mode === "plan" && !interactionModeEnabled) return; - if (mode === interactionMode) return; - setComposerDraftInteractionMode(composerDraftTarget, mode); - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); - } - scheduleComposerFocus(); - }, - [ - interactionMode, - interactionModeEnabled, - isLocalDraftThread, - scheduleComposerFocus, - composerDraftTarget, - setComposerDraftInteractionMode, - setDraftThreadContext, - ], - ); - const toggleInteractionMode = useCallback(() => { + const handleInteractionModeChange = (mode: ProviderInteractionMode) => { + if (mode === "plan" && !interactionModeEnabled) return; + if (mode === interactionMode) return; + setComposerDraftInteractionMode(composerDraftTarget, mode); + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); + } + scheduleComposerFocus(); + }; + const handleInteractionModeChangeStable = useStableCallback(handleInteractionModeChange); + const toggleInteractionMode = () => { if (!interactionModeEnabled) return; handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); - }, [handleInteractionModeChange, interactionMode, interactionModeEnabled]); - const openProviderSetup = useCallback( - (instanceId: ProviderInstanceId) => { - void navigate({ - to: "/settings/providers", - search: { environmentId, instanceId }, - }); - }, - [environmentId, navigate], - ); + }; + const toggleInteractionModeStable = useStableCallback(toggleInteractionMode); + const openProviderSetup = (instanceId: ProviderInstanceId) => { + void navigate({ + to: "/settings/providers", + search: { environmentId, instanceId }, + }); + }; + const openProviderSetupStable = useStableCallback(openProviderSetup); const canOperatePreview = useEnvironmentScope( activeThreadRef?.environmentId ?? null, AuthPreviewOperateScope, ); - const createBrowserSurface = useCallback( - (profileId?: string) => { - if (!activeThreadRef || !canOperatePreview) return; - void addBrowserSurface({ - threadRef: activeThreadRef, - openPreview, - ...(profileId === undefined ? {} : { profileId }), - }).then((result) => { - if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - if (error instanceof BrowserSettingsReadError) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open browser", - description: error.message, - }), - ); - } - }); - }, - [activeThreadRef, canOperatePreview, openPreview], - ); - const addDiffSurface = useCallback(() => { + const createBrowserSurface = (profileId?: string) => { + if (!activeThreadRef || !canOperatePreview) return; + void addBrowserSurface({ + threadRef: activeThreadRef, + openPreview, + ...(profileId === undefined ? {} : { profileId }), + }).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open browser", + description: error.message, + }), + ); + } + }); + }; + const createBrowserSurfaceStable = useStableCallback(createBrowserSurface); + const addDiffSurface = () => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); - const openChangesFromThreadPanel = useCallback(() => { + }; + const addDiffSurfaceStable = useStableCallback(addDiffSurface); + const openChangesFromThreadPanel = () => { addDiffSurface(); - }, [addDiffSurface]); - const addFilesSurface = useCallback(() => { + }; + const openChangesFromThreadPanelStable = useStableCallback(openChangesFromThreadPanel); + const addFilesSurface = () => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); - }, [activeProject, activeThreadRef]); + }; + const addFilesSurfaceStable = useStableCallback(addFilesSurface); const supportsThreadPullRequests = serverConfig?.environment.capabilities.threadPullRequests === true; const visiblePullRequests = visibleThreadPullRequests( @@ -5488,22 +5395,24 @@ export default function ChatView(props: ChatViewProps) { const visiblePullRequestCount = visiblePullRequests.length; const pullRequestsSurfaceAvailable = isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0; - const addPullRequestsSurface = useCallback(() => { + const addPullRequestsSurface = () => { if (!activeThreadRef || !pullRequestsSurfaceAvailable) return; useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); - }, [activeThreadRef, pullRequestsSurfaceAvailable]); + }; + const addPullRequestsSurfaceStable = useStableCallback(addPullRequestsSurface); const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( activeThreadRef?.environmentId ?? null, ); const [deviceSetupThread, setDeviceSetupThread] = useState(null); - const addDeviceSurface = useCallback(() => { + const addDeviceSurface = () => { if (!activeThreadRef) return; if (!deviceState.onboardingCompleted || deviceState.hostStatus === "disabled") { setDeviceSetupThread(activeThreadRef); return; } useRightPanelStore.getState().open(activeThreadRef, "device"); - }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]); + }; + const addDeviceSurfaceStable = useStableCallback(addDeviceSurface); // A device the agent opens floats over chat like an agent-driven browser, // or becomes a panel tab when floating previews are off. Sessions opened by // another client arrive the same way; sheet layouts get neither. The first @@ -5617,13 +5526,11 @@ export default function ChatView(props: ChatViewProps) { ); if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(activeThreadRef); }, [activePreviewMiniPlayer, activeThreadRef, deviceState.sessions, deviceStateLoaded]); - const openFileSurface = useCallback( - (relativePath: string) => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); - }, - [activeProject, activeThreadRef], - ); + const openFileSurface = (relativePath: string) => { + if (!activeThreadRef || !activeProject) return; + useRightPanelStore.getState().openFile(activeThreadRef, relativePath); + }; + const openFileSurfaceStable = useStableCallback(openFileSurface); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. const persistedLinkedThreadPullRequest = isServerThread @@ -5890,7 +5797,7 @@ export default function ChatView(props: ChatViewProps) { shouldUsePlanSidebarSheet, threadDetailLoading, ]); - const closePreviewPanel = useCallback(() => { + const closePreviewPanel = () => { if (activeThreadRef) { // Closing the panel on a live browser or device floats it instead of dropping it. if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) { @@ -5905,8 +5812,9 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().setMaximized(activeThreadRef, false); useRightPanelStore.getState().close(activeThreadRef); } - }, [activeRightPanelSurface, activeThreadRef]); - const togglePreviewPanel = useCallback(() => { + }; + const closePreviewPanelStable = useStableCallback(closePreviewPanel); + const togglePreviewPanel = () => { if (!activeThreadRef || !browserAvailable) return; if (previewPanelOpen) { closePreviewPanel(); @@ -5919,16 +5827,9 @@ export default function ChatView(props: ChatViewProps) { } else { createBrowserSurface(); } - }, [ - activePreviewState.activeTabId, - activeThreadRef, - browserAvailable, - canOperatePreview, - closePreviewPanel, - createBrowserSurface, - previewPanelOpen, - ]); - const addTerminalSurface = useCallback(() => { + }; + const togglePreviewPanelStable = useStableCallback(togglePreviewPanel); + const addTerminalSurface = () => { if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) return; const cwd = gitCwd ?? activeProject.workspaceRoot; const terminalId = allocateTerminalId(); @@ -5947,151 +5848,118 @@ export default function ChatView(props: ChatViewProps) { }), }, }); - }, [ - activeProject, - activeThreadId, - activeThreadRef, - activeThreadWorktreePath, - allocateTerminalId, - gitCwd, - openTerminal, - hasTerminalWriteAccess, - ]); - const splitPanelTerminal = useCallback( - (direction: "horizontal" | "vertical" = "horizontal") => { - if ( - !hasTerminalWriteAccess() || - !activeThreadRef || - !activeThreadId || - !activeProject || - activeRightPanelSurface?.kind !== "terminal" || - activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP - ) { - return; - } - const terminalId = allocateTerminalId(); - const cwd = gitCwd ?? activeProject.workspaceRoot; - useRightPanelStore - .getState() - .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); - setTerminalFocusRequestId((value) => value + 1); - void openTerminal({ - environmentId: activeThreadRef.environmentId, - input: { - threadId: activeThreadId, - terminalId, - cwd, - ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), - env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.workspaceRoot }, - worktreePath: activeThreadWorktreePath, - }), - }, - }); - }, - [ - activeProject, - activeRightPanelSurface, - activeThreadId, - activeThreadRef, - activeThreadWorktreePath, - allocateTerminalId, - gitCwd, - openTerminal, - hasTerminalWriteAccess, - ], - ); - const splitPanelTerminalVertical = useCallback(() => { + }; + const addTerminalSurfaceStable = useStableCallback(addTerminalSurface); + const splitPanelTerminal = (direction: "horizontal" | "vertical" = "horizontal") => { + if ( + !hasTerminalWriteAccess() || + !activeThreadRef || + !activeThreadId || + !activeProject || + activeRightPanelSurface?.kind !== "terminal" || + activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP + ) { + return; + } + const terminalId = allocateTerminalId(); + const cwd = gitCwd ?? activeProject.workspaceRoot; + useRightPanelStore + .getState() + .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); + setTerminalFocusRequestId((value) => value + 1); + void openTerminal({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadId, + terminalId, + cwd, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.workspaceRoot }, + worktreePath: activeThreadWorktreePath, + }), + }, + }); + }; + const splitPanelTerminalStable = useStableCallback(splitPanelTerminal); + const splitPanelTerminalVertical = () => { splitPanelTerminal("vertical"); - }, [splitPanelTerminal]); - const activatePanelTerminal = useCallback( - (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; - useRightPanelStore - .getState() - .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, - [activeRightPanelSurface, activeThreadRef], - ); - const closePanelTerminal = useCallback( - (terminalId: string) => { - if ( - !hasTerminalWriteAccess() || - !activeThreadRef || - activeRightPanelSurface?.kind !== "terminal" - ) - return; - void closeTerminalMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, - }); - storeCloseTerminal(activeThreadRef, terminalId); - useRightPanelStore - .getState() - .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, - [ - hasTerminalWriteAccess, - activeRightPanelSurface, - activeThreadRef, - closeTerminalMutation, - storeCloseTerminal, - ], - ); - const requestCloseTerminal = useCallback( - (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); - void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { - closeTerminal(terminalId); - } - }); - }, - [hasTerminalWriteAccess, activeTerminalLabelsById, closeTerminal, environmentId], - ); - const requestClosePanelTerminal = useCallback( - (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); - void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { - closePanelTerminal(terminalId); - } - }); - }, - [hasTerminalWriteAccess, activeTerminalLabelsById, closePanelTerminal, environmentId], - ); - const activateRightPanelSurface = useCallback( - (surface: RightPanelSurface) => { - if (!activeThreadRef) return; - useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); - if (surface.kind === "preview" && surface.resourceId) { - setActivePreviewTab(activeThreadRef, surface.resourceId); - } - if (surface.kind === "terminal") { - setTerminalFocusRequestId((value) => value + 1); + }; + const splitPanelTerminalVerticalStable = useStableCallback(splitPanelTerminalVertical); + const activatePanelTerminal = (terminalId: string) => { + if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; + useRightPanelStore + .getState() + .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + setTerminalFocusRequestId((value) => value + 1); + }; + const activatePanelTerminalStable = useStableCallback(activatePanelTerminal); + const closePanelTerminal = (terminalId: string) => { + if ( + !hasTerminalWriteAccess() || + !activeThreadRef || + activeRightPanelSurface?.kind !== "terminal" + ) + return; + void closeTerminalMutation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, + }); + storeCloseTerminal(activeThreadRef, terminalId); + useRightPanelStore + .getState() + .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + setTerminalFocusRequestId((value) => value + 1); + }; + const closePanelTerminalStable = useStableCallback(closePanelTerminal); + const requestCloseTerminal = (terminalId: string) => { + if (!hasTerminalWriteAccess()) return; + const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); + void confirmTerminalClose([label]).then((confirmed) => { + if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { + closeTerminal(terminalId); } - if (surface.kind === "diff" && !diffOpen) { - onDiffPanelOpen?.(); + }); + }; + const requestCloseTerminalStable = useStableCallback(requestCloseTerminal); + const requestClosePanelTerminal = (terminalId: string) => { + if (!hasTerminalWriteAccess()) return; + const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); + void confirmTerminalClose([label]).then((confirmed) => { + if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { + closePanelTerminal(terminalId); } - }, - [activeThreadRef, diffOpen, onDiffPanelOpen], - ); - const toggleRightPanel = useCallback(() => { + }); + }; + const requestClosePanelTerminalStable = useStableCallback(requestClosePanelTerminal); + const activateRightPanelSurface = (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); + if (surface.kind === "preview" && surface.resourceId) { + setActivePreviewTab(activeThreadRef, surface.resourceId); + } + if (surface.kind === "terminal") { + setTerminalFocusRequestId((value) => value + 1); + } + if (surface.kind === "diff" && !diffOpen) { + onDiffPanelOpen?.(); + } + }; + const activateRightPanelSurfaceStable = useStableCallback(activateRightPanelSurface); + const toggleRightPanel = () => { if (!activeThreadRef) return; if (rightPanelOpen) { closePreviewPanel(); return; } useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }, [activeThreadRef, closePreviewPanel, rightPanelOpen]); - const toggleThreadPanel = useCallback(() => { + }; + const toggleRightPanelStable = useStableCallback(toggleRightPanel); + const toggleThreadPanel = () => { if (!activeThreadRef) return; useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation); - }, [activeThreadRef, threadPanelPresentation]); + }; + const toggleThreadPanelStable = useStableCallback(toggleThreadPanel); // A thread started for a link the OS opened shows its browser maximized, like a browser window. useEffect(() => { if (!canMaximizeRightPanel) return; @@ -6099,70 +5967,61 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().setMaximized(routeThreadRef, true); } }, [canMaximizeRightPanel, routeThreadRef]); - const toggleRightPanelMaximized = useCallback(() => { + const toggleRightPanelMaximized = () => { if (!canMaximizeRightPanel || !activeThreadRef) return; useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized); - }, [activeThreadRef, canMaximizeRightPanel, rightPanelMaximized]); - const cleanupRightPanelSurfaces = useCallback( - (surfaces: readonly RightPanelSurface[]) => { - if (!activeThreadRef) return; - for (const surface of surfaces) { - // Without preview access only the local surface goes away. The - // server record is in-memory bookkeeping, not a live view, and stays - // listed until a client with the scope closes it or the server restarts. - if (canOperatePreview && surface.kind === "preview" && surface.resourceId) { - void closePreviewSession({ - closePreview, - snapshot: activePreviewState.sessions[surface.resourceId] ?? null, - tabId: surface.resourceId, - threadRef: activeThreadRef, + }; + const toggleRightPanelMaximizedStable = useStableCallback(toggleRightPanelMaximized); + const cleanupRightPanelSurfaces = (surfaces: readonly RightPanelSurface[]) => { + if (!activeThreadRef) return; + for (const surface of surfaces) { + // Without preview access only the local surface goes away. The + // server record is in-memory bookkeeping, not a live view, and stays + // listed until a client with the scope closes it or the server restarts. + if (canOperatePreview && surface.kind === "preview" && surface.resourceId) { + void closePreviewSession({ + closePreview, + snapshot: activePreviewState.sessions[surface.resourceId] ?? null, + tabId: surface.resourceId, + threadRef: activeThreadRef, + }); + } + if ( + surface.kind === "terminal" && + readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope) + ) { + for (const terminalId of surface.terminalIds) { + storeCloseTerminal(activeThreadRef, terminalId); + void closeTerminalMutation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, }); } - if ( - surface.kind === "terminal" && - readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope) - ) { - for (const terminalId of surface.terminalIds) { - storeCloseTerminal(activeThreadRef, terminalId); - void closeTerminalMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, - }); - } - } - } - }, - [ - activeThreadRef, - activePreviewState.sessions, - canOperatePreview, - closePreview, - closeTerminalMutation, - storeCloseTerminal, - ], - ); - const closeAfterAgentBrowserConfirmation = useCallback( - (surfaces: readonly RightPanelSurface[], closeSurfaces: () => void) => { - const message = agentControlledBrowserCloseConfirmation( - surfaces, - activePreviewState.desktopByTabId, - ); - if (!message) { - closeSurfaces(); - return; } - const localApi = readLocalApi(); - if (!localApi) return; - void localApi.dialogs.confirm(message, { variant: "destructive" }).then( - (confirmed) => { - if (confirmed) closeSurfaces(); - }, - () => undefined, - ); - }, - [activePreviewState.desktopByTabId], - ); - const syncActivePreviewSurface = useCallback(() => { + } + }; + const closeAfterAgentBrowserConfirmation = ( + surfaces: readonly RightPanelSurface[], + closeSurfaces: () => void, + ) => { + const message = agentControlledBrowserCloseConfirmation( + surfaces, + activePreviewState.desktopByTabId, + ); + if (!message) { + closeSurfaces(); + return; + } + const localApi = readLocalApi(); + if (!localApi) return; + void localApi.dialogs.confirm(message, { variant: "destructive" }).then( + (confirmed) => { + if (confirmed) closeSurfaces(); + }, + () => undefined, + ); + }; + const syncActivePreviewSurface = () => { if (!activeThreadRef) return; const nextActiveSurface = selectActiveRightPanelSurface( useRightPanelStore.getState().byThreadKey, @@ -6171,110 +6030,80 @@ export default function ChatView(props: ChatViewProps) { if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId); } - }, [activeThreadRef]); - const finishRightPanelSurfaceClose = useCallback( - (surfaces: readonly RightPanelSurface[]) => { - if (!activeThreadRef) return; - const store = useRightPanelStore.getState(); - const activeId = selectThreadRightPanelState( - store.byThreadKey, - activeThreadRef, - ).activeSurfaceId; - const ordered = surfaces.toSorted( - (left, right) => Number(left.id === activeId) - Number(right.id === activeId), + }; + const finishRightPanelSurfaceClose = (surfaces: readonly RightPanelSurface[]) => { + if (!activeThreadRef) return; + const store = useRightPanelStore.getState(); + const activeId = selectThreadRightPanelState( + store.byThreadKey, + activeThreadRef, + ).activeSurfaceId; + const ordered = surfaces.toSorted( + (left, right) => Number(left.id === activeId) - Number(right.id === activeId), + ); + for (const surface of ordered) { + cleanupRightPanelSurfaces([surface]); + store.closeSurface(activeThreadRef, surface.id); + } + syncActivePreviewSurface(); + }; + const closeRightPanelSurface = (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + const finishClose = () => finishRightPanelSurfaceClose([surface]); + if (surface.kind === "preview") { + closeAfterAgentBrowserConfirmation([surface], finishClose); + return; + } + if ( + surface.kind !== "terminal" || + !readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope) + ) { + finishClose(); + return; + } + const activeLabel = + activeTerminalLabelsById.get(surface.activeTerminalId) ?? + getTerminalLabel(surface.activeTerminalId); + const otherLabels = surface.terminalIds + .filter((terminalId) => terminalId !== surface.activeTerminalId) + .map( + (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId), ); - for (const surface of ordered) { - cleanupRightPanelSurfaces([surface]); - store.closeSurface(activeThreadRef, surface.id); - } - syncActivePreviewSurface(); - }, - [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], - ); - const closeRightPanelSurface = useCallback( - (surface: RightPanelSurface) => { - if (!activeThreadRef) return; - const finishClose = () => finishRightPanelSurfaceClose([surface]); - if (surface.kind === "preview") { - closeAfterAgentBrowserConfirmation([surface], finishClose); - return; - } - if ( - surface.kind !== "terminal" || - !readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope) - ) { + void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => { + if (confirmed) { finishClose(); - return; } - const activeLabel = - activeTerminalLabelsById.get(surface.activeTerminalId) ?? - getTerminalLabel(surface.activeTerminalId); - const otherLabels = surface.terminalIds - .filter((terminalId) => terminalId !== surface.activeTerminalId) - .map( - (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId), - ); - void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => { - if (confirmed) { - finishClose(); - } - }); - }, - [ - activeThreadRef, - activeTerminalLabelsById, - closeAfterAgentBrowserConfirmation, - finishRightPanelSurfaceClose, - ], - ); - const closeOtherRightPanelSurfaces = useCallback( - (surface: RightPanelSurface) => { - if (!activeThreadRef) return; - const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id); - const finishClose = () => finishRightPanelSurfaceClose(surfaces); - closeAfterAgentBrowserConfirmation(surfaces, finishClose); - }, - [ - activeThreadRef, - closeAfterAgentBrowserConfirmation, - finishRightPanelSurfaceClose, - rightPanelState.surfaces, - ], - ); - const closeRightPanelSurfacesToRight = useCallback( - (surface: RightPanelSurface) => { - if (!activeThreadRef) return; - const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); - if (surfaceIndex < 0) return; - const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1); - const finishClose = () => finishRightPanelSurfaceClose(surfaces); - closeAfterAgentBrowserConfirmation(surfaces, finishClose); - }, - [ - activeThreadRef, - closeAfterAgentBrowserConfirmation, - finishRightPanelSurfaceClose, - rightPanelState.surfaces, - ], - ); - const closeAllRightPanelSurfaces = useCallback(() => { + }); + }; + const closeRightPanelSurfaceStable = useStableCallback(closeRightPanelSurface); + const closeOtherRightPanelSurfaces = (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id); + const finishClose = () => finishRightPanelSurfaceClose(surfaces); + closeAfterAgentBrowserConfirmation(surfaces, finishClose); + }; + const closeOtherRightPanelSurfacesStable = useStableCallback(closeOtherRightPanelSurfaces); + const closeRightPanelSurfacesToRight = (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); + if (surfaceIndex < 0) return; + const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1); + const finishClose = () => finishRightPanelSurfaceClose(surfaces); + closeAfterAgentBrowserConfirmation(surfaces, finishClose); + }; + const closeRightPanelSurfacesToRightStable = useStableCallback(closeRightPanelSurfacesToRight); + const closeAllRightPanelSurfaces = () => { if (!activeThreadRef) return; const finishClose = () => finishRightPanelSurfaceClose(rightPanelState.surfaces); closeAfterAgentBrowserConfirmation(rightPanelState.surfaces, finishClose); - }, [ - activeThreadRef, - closeAfterAgentBrowserConfirmation, - finishRightPanelSurfaceClose, - rightPanelState.surfaces, - ]); - const moveRightPanelSurface = useCallback( - (surfaceId: string, toIndex: number) => { - if (activeThreadRef) - useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex); - }, - [activeThreadRef], - ); - const copyRightPanelFilePath = useCallback((relativePath: string) => { + }; + const closeAllRightPanelSurfacesStable = useStableCallback(closeAllRightPanelSurfaces); + const moveRightPanelSurface = (surfaceId: string, toIndex: number) => { + if (activeThreadRef) + useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex); + }; + const moveRightPanelSurfaceStable = useStableCallback(moveRightPanelSurface); + const copyRightPanelFilePath = (relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( stackedThreadToast({ @@ -6304,88 +6133,80 @@ export default function ChatView(props: ChatViewProps) { ); }, ); - }, []); + }; + const copyRightPanelFilePathStable = useStableCallback(copyRightPanelFilePath); useEffect( () => subscribePreviewAction((action) => { - if (action === "toggle-panel") togglePreviewPanel(); + if (action === "toggle-panel") togglePreviewPanelStable(); }), - [togglePreviewPanel], + [togglePreviewPanelStable], ); - const persistThreadSettingsForNextTurn = useCallback( - async (input: { - threadId: ThreadId; - createdAt: string; - branch?: string; - runtimeMode: RuntimeMode; - interactionMode: ProviderInteractionMode; - }): Promise> => { - if (!serverThread) { - return AsyncResult.success(undefined); - } - - let result: AtomCommandResult = AsyncResult.success(undefined); - const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ - currentModelSelection: serverThread.modelSelection, - currentBranch: serverThread.branch, - ...(input.branch ? { nextBranch: input.branch } : {}), - }); - if (metadataUpdate) { - result = mapAtomCommandResult( - await updateThreadMetadata({ - environmentId, - input: { - threadId: input.threadId, - ...metadataUpdate, - }, - }), - () => undefined, - ); - if (result._tag === "Failure") { - return result; - } + const persistThreadSettingsForNextTurn = async (input: { + threadId: ThreadId; + createdAt: string; + branch?: string; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + }): Promise> => { + if (!serverThread) { + return AsyncResult.success(undefined); + } + + let result: AtomCommandResult = AsyncResult.success(undefined); + const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ + currentModelSelection: serverThread.modelSelection, + currentBranch: serverThread.branch, + ...(input.branch ? { nextBranch: input.branch } : {}), + }); + if (metadataUpdate) { + result = mapAtomCommandResult( + await updateThreadMetadata({ + environmentId, + input: { + threadId: input.threadId, + ...metadataUpdate, + }, + }), + () => undefined, + ); + if (result._tag === "Failure") { + return result; } + } - if (input.runtimeMode !== serverThread.runtimeMode) { - result = mapAtomCommandResult( - await setThreadRuntimeMode({ - environmentId, - input: { - threadId: input.threadId, - runtimeMode: input.runtimeMode, - createdAt: input.createdAt, - }, - }), - () => undefined, - ); - if (result._tag === "Failure") { - return result; - } + if (input.runtimeMode !== serverThread.runtimeMode) { + result = mapAtomCommandResult( + await setThreadRuntimeMode({ + environmentId, + input: { + threadId: input.threadId, + runtimeMode: input.runtimeMode, + createdAt: input.createdAt, + }, + }), + () => undefined, + ); + if (result._tag === "Failure") { + return result; } + } - if (input.interactionMode !== serverThread.interactionMode) { - result = mapAtomCommandResult( - await setThreadInteractionMode({ - environmentId, - input: { - threadId: input.threadId, - interactionMode: input.interactionMode, - createdAt: input.createdAt, - }, - }), - () => undefined, - ); - } - return result; - }, - [ - environmentId, - serverThread, - setThreadInteractionMode, - setThreadRuntimeMode, - updateThreadMetadata, - ], - ); + if (input.interactionMode !== serverThread.interactionMode) { + result = mapAtomCommandResult( + await setThreadInteractionMode({ + environmentId, + input: { + threadId: input.threadId, + interactionMode: input.interactionMode, + createdAt: input.createdAt, + }, + }), + () => undefined, + ); + } + return result; + }; // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during // thread switches. LegendList fires scroll events with isAtEnd=false while @@ -6413,7 +6234,7 @@ export default function ChatView(props: ChatViewProps) { readonly userScrollGeneration: number; } | null>(null); const anchorScrollRestoreFrameRef = useRef(null); - const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { + const cancelTimelineLiveFollowForUserNavigation = () => { cancelPositionRestoreRef.current?.(); anchorUserScrollGenerationRef.current += 1; const wasProgrammaticScrollMode = timelineScrollModeRef.current !== "free-scrolling"; @@ -6453,14 +6274,17 @@ export default function ChatView(props: ChatViewProps) { scrollNode.scrollTop = currentScrollTop; } } - }, []); - const cancelTimelineLiveFollowForUserNavigationRef = useRef( + }; + const cancelTimelineLiveFollowForUserNavigationStable = useStableCallback( cancelTimelineLiveFollowForUserNavigation, ); + const cancelTimelineLiveFollowForUserNavigationRef = useRef( + cancelTimelineLiveFollowForUserNavigationStable, + ); useEffect(() => { cancelTimelineLiveFollowForUserNavigationRef.current = - cancelTimelineLiveFollowForUserNavigation; - }, [cancelTimelineLiveFollowForUserNavigation]); + cancelTimelineLiveFollowForUserNavigationStable; + }, [cancelTimelineLiveFollowForUserNavigationStable]); useEffect(() => { const previous = observedTimelineActivityRef.current; const dispatchedUserItem = @@ -6498,13 +6322,13 @@ export default function ChatView(props: ChatViewProps) { setShowScrollToBottom(false); setTimelineAnchor({ threadKey: activeThreadKey, messageId }); }, [activeActivityRun, activeThreadKey, isServerThread, serverProjection, threadStatus]); - const timelineRealContentOverflowsViewport = useCallback( - (list?: LegendListRef | null) => - timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), { - composerInset: composerTimelineInset, - anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, - }), - [composerTimelineInset], + const timelineRealContentOverflowsViewport = (list?: LegendListRef | null) => + timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), { + composerInset: composerTimelineInset, + anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, + }); + const timelineRealContentOverflowsViewportStable = useStableCallback( + timelineRealContentOverflowsViewport, ); const pageScrollControllerRef = useRef | null>( null, @@ -6512,8 +6336,11 @@ export default function ChatView(props: ChatViewProps) { const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => { timelineScrollIntentRef.current = key === "PageUp" ? "away-from-end" : "toward-end"; composerRef.current?.collapseForTimelineScrollKey(key); - if ((key === "PageUp" && timelineRealContentOverflowsViewport()) || !isTimelineAtLogicalEnd()) { - cancelTimelineLiveFollowForUserNavigation(); + if ( + (key === "PageUp" && timelineRealContentOverflowsViewportStable()) || + !isTimelineAtLogicalEndStable() + ) { + cancelTimelineLiveFollowForUserNavigationStable(); } }); useEffect(() => { @@ -6531,18 +6358,21 @@ export default function ChatView(props: ChatViewProps) { } }; }, [composerOverlayElement]); - const onComposerPageScrollKeyDown = useCallback((key: PageScrollKey) => { + const onComposerPageScrollKeyDown = (key: PageScrollKey) => { pageScrollControllerRef.current?.handleKeyDown(key); - }, []); - const onComposerPageScrollKeyUp = useCallback((key: string) => { + }; + const onComposerPageScrollKeyDownStable = useStableCallback(onComposerPageScrollKeyDown); + const onComposerPageScrollKeyUp = (key: string) => { pageScrollControllerRef.current?.handleKeyUp(key); - }, []); - const onComposerPageScrollRelease = useCallback(() => { + }; + const onComposerPageScrollKeyUpStable = useStableCallback(onComposerPageScrollKeyUp); + const onComposerPageScrollRelease = () => { pageScrollControllerRef.current?.releaseActiveKey(); - }, []); + }; + const onComposerPageScrollReleaseStable = useStableCallback(onComposerPageScrollRelease); // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. - const scrollToEnd = useCallback((animated = false) => { + const scrollToEnd = (animated: boolean = false) => { cancelPositionRestoreRef.current?.(); isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; @@ -6558,7 +6388,8 @@ export default function ChatView(props: ChatViewProps) { requestAnimationFrame(() => { void legendListRef.current?.scrollToEnd?.({ animated }); }); - }, []); + }; + const scrollToEndStable = useStableCallback(scrollToEnd); useEffect(() => { let removeListeners: (() => void) | null = null; let frame: number | null = null; @@ -6583,7 +6414,7 @@ export default function ChatView(props: ChatViewProps) { // while pinned at the end produces no scroll event, never re-arms, // and streaming silently stops following. Underflowing content can't // scroll at all, so nothing there should break follow. - const contentScrollsUp = () => timelineRealContentOverflowsViewport(); + const contentScrollsUp = () => timelineRealContentOverflowsViewportStable(); // The follow re-arm band, not the strict flag: streaming growth makes // isAtEnd flicker false for a frame before the follow scroll catches // up, and a gesture landing in that window while still pinned would @@ -6679,7 +6510,7 @@ export default function ChatView(props: ChatViewProps) { handleManualNavigation(); } composerRef.current?.collapseForTimelineScrollKey(event.key); - if (isTimelineAtLogicalEnd()) { + if (isTimelineAtLogicalEndStable()) { composerRef.current?.restoreAfterTimelineReachedEnd(); } break; @@ -6713,9 +6544,9 @@ export default function ChatView(props: ChatViewProps) { } removeListeners?.(); }; - }, [activeThread?.id, isTimelineAtLogicalEnd, timelineRealContentOverflowsViewport]); + }, [activeThread?.id, isTimelineAtLogicalEndStable, timelineRealContentOverflowsViewportStable]); - const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { + const onTimelineAnchorReady = (messageId: MessageId, anchorIndex: number) => { if (pendingTimelineAnchorRef.current === messageId) { pendingTimelineAnchorRef.current = null; } @@ -6764,8 +6595,9 @@ export default function ChatView(props: ChatViewProps) { }); }; requestAnimationFrame(() => positionAnchor(12)); - }, []); - const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => { + }; + const onTimelineAnchorReadyStable = useStableCallback(onTimelineAnchorReady); + const onTimelineAnchorSizeChanged = (messageId: MessageId) => { if (settledTimelineAnchorRef.current !== messageId) { return; } @@ -6805,13 +6637,15 @@ export default function ChatView(props: ChatViewProps) { } } }); - }, []); + }; + const onTimelineAnchorSizeChangedStable = useStableCallback(onTimelineAnchorSizeChanged); - const onToolOutputCollapsedAtEnd = useCallback(() => { + const onToolOutputCollapsedAtEnd = () => { composerRef.current?.restoreAfterTimelineReachedEnd(); - }, [composerRef]); + }; + const onToolOutputCollapsedAtEndStable = useStableCallback(onToolOutputCollapsedAtEnd); - const onIsAtEndChange = useCallback((isAtEnd: boolean) => { + const onIsAtEndChange = (isAtEnd: boolean) => { if ( !isAtEnd && liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current @@ -6841,7 +6675,8 @@ export default function ChatView(props: ChatViewProps) { liveFollowUserScrollGenerationRef.current = null; showScrollDebouncer.current.maybeExecute(); } - }, []); + }; + const onIsAtEndChangeStable = useStableCallback(onIsAtEndChange); useLayoutEffect(() => { if (timelineScrollModeRef.current !== "anchoring-new-turn") return; @@ -6853,11 +6688,11 @@ export default function ChatView(props: ChatViewProps) { timelineEntries, }) ) { - scrollToEnd(); + scrollToEndStable(); } }, [ activeRunningTurnId, - scrollToEnd, + scrollToEndStable, timelineAnchorMessageId, timelineEntries, timelineLiveFollowEnabled, @@ -6885,12 +6720,12 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { - focusComposer(); + focusComposerStable(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposer, terminalUiState.terminalOpen]); + }, [activeThread?.id, focusComposerStable, terminalUiState.terminalOpen]); // Tabbing back into the app lands focus wherever it last was, often the right panel or the // body. Put it in the composer unless something that takes typing already holds it. The @@ -6910,7 +6745,7 @@ export default function ChatView(props: ChatViewProps) { frame = null; // A full-screen app owns the page; refocusing the composer would close it. if (document.querySelector("[data-mcp-app-fullscreen]") !== null) return; - if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposer(); + if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposerStable(); }); }); }; @@ -6919,7 +6754,7 @@ export default function ChatView(props: ChatViewProps) { window.removeEventListener("focus", onWindowFocus); if (frame !== null) window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposer, isMobileViewport, terminalUiState.terminalOpen]); + }, [activeThread?.id, focusComposerStable, isMobileViewport, terminalUiState.terminalOpen]); useEffect(() => { if (!activeThread?.id) return; @@ -6940,7 +6775,7 @@ export default function ChatView(props: ChatViewProps) { for (const removedMessage of removedMessages) { const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage); if (previewUrls.length > 0) { - handoffAttachmentPreviews(removedMessage.id, previewUrls); + handoffAttachmentPreviewsStable(removedMessage.id, previewUrls); continue; } revokeUserMessagePreviewUrls(removedMessage); @@ -6952,7 +6787,7 @@ export default function ChatView(props: ChatViewProps) { activeMessageCount, activeThread?.id, serverAcknowledgedUserMessageIds, - handoffAttachmentPreviews, + handoffAttachmentPreviewsStable, optimisticUserMessages, ]); @@ -6970,9 +6805,10 @@ export default function ChatView(props: ChatViewProps) { setExpandedImage(null); }, [resetLocalDispatch, threadId]); - const closeExpandedImage = useCallback(() => { + const closeExpandedImage = () => { setExpandedImage(null); - }, []); + }; + const closeExpandedImageStable = useStableCallback(closeExpandedImage); const activeWorktreePath = activeThread?.worktreePath ?? null; const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ @@ -7017,87 +6853,86 @@ export default function ChatView(props: ChatViewProps) { : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); - const publishComposerOverlayHeight = useCallback( - (height: number) => { - const nextHeight = Math.ceil(height); - if (nextHeight <= 0) return; - const isResting = overlayComposerIsResting({ - composerMounted: composerMountedRef.current, - composerReportedResting: composerRestingRef.current, - }); - const modelStrip = composerOverlayElement?.querySelector( - '[data-composer-model-strip="true"]', - ); - // The model-only strip disappears on expansion; counting it again would - // add 32px of timeline padding as soon as the empty composer collapses. - const restingOnlyHeight = - modelStrip && isResting - ? Math.max( - 0, - modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop), - ) - : 0; - const nextInset = resolveComposerTimelineInset({ - currentInset: composerTimelineInsetRef.current, - overlayHeight: nextHeight, - isResting, - restingOnlyHeight, - }); - if (composerTimelineInsetRef.current !== nextInset) { - composerTimelineInsetRef.current = nextInset; - setComposerTimelineInset(nextInset); - } - const mainSurface = composerOverlayElement?.querySelector( - '[data-chat-composer-main-surface="true"]', - ); - const button = composerOverlayElement?.parentElement?.querySelector( - 'button[aria-label="Scroll to end"]', - ); - const clearance = - composerOverlayElement && mainSurface && button - ? resolveScrollToEndClearance({ - overlayHeight: nextHeight, - mainSurfaceTop: mainSurface.getBoundingClientRect().top, - button: button.getBoundingClientRect(), - attachments: Array.from( - composerOverlayElement.querySelectorAll( - '[data-composer-banner-surface="attached"]', - ), - (element) => element.getBoundingClientRect(), + const publishComposerOverlayHeight = (height: number) => { + const nextHeight = Math.ceil(height); + if (nextHeight <= 0) return; + const isResting = overlayComposerIsResting({ + composerMounted: composerMountedRef.current, + composerReportedResting: composerRestingRef.current, + }); + const modelStrip = composerOverlayElement?.querySelector( + '[data-composer-model-strip="true"]', + ); + // The model-only strip disappears on expansion; counting it again would + // add 32px of timeline padding as soon as the empty composer collapses. + const restingOnlyHeight = + modelStrip && isResting + ? Math.max( + 0, + modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop), + ) + : 0; + const nextInset = resolveComposerTimelineInset({ + currentInset: composerTimelineInsetRef.current, + overlayHeight: nextHeight, + isResting, + restingOnlyHeight, + }); + if (composerTimelineInsetRef.current !== nextInset) { + composerTimelineInsetRef.current = nextInset; + setComposerTimelineInset(nextInset); + } + const mainSurface = composerOverlayElement?.querySelector( + '[data-chat-composer-main-surface="true"]', + ); + const button = composerOverlayElement?.parentElement?.querySelector( + 'button[aria-label="Scroll to end"]', + ); + const clearance = + composerOverlayElement && mainSurface && button + ? resolveScrollToEndClearance({ + overlayHeight: nextHeight, + mainSurfaceTop: mainSurface.getBoundingClientRect().top, + button: button.getBoundingClientRect(), + attachments: Array.from( + composerOverlayElement.querySelectorAll( + '[data-composer-banner-surface="attached"]', ), - }) - : nextHeight; - setScrollToEndClearance(clearance); - }, - [composerOverlayElement], - ); + (element) => element.getBoundingClientRect(), + ), + }) + : nextHeight; + setScrollToEndClearance(clearance); + }; + const publishComposerOverlayHeightStable = useStableCallback(publishComposerOverlayHeight); // The composer reports its resting flag from a layout effect, which runs // before this component's own layout effects and before any resize // observation, so every measurement below sees the flag for its layout. // Only the flag is stored here: the stored height still belongs to the // previous layout, and the composer publishes the new layout's height // itself once it has measured it. - const onComposerRestingChange = useCallback((resting: boolean) => { + const onComposerRestingChange = (resting: boolean) => { composerRestingRef.current = resting; - }, []); + }; + const onComposerRestingChangeStable = useStableCallback(onComposerRestingChange); // A held reservation belongs to the previous thread's draft. Rebuild it from // this thread's overlay so a tall draft elsewhere does not pad this one. useLayoutEffect(() => { if (!composerOverlayElement) return; composerTimelineInsetRef.current = 0; - publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); - }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeight]); + publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height); + }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeightStable]); useLayoutEffect(() => { if (!composerOverlayElement) return; const updateHeight = () => { - publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); + publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height); }; updateHeight(); return observeResize(composerOverlayElement, updateHeight); - }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]); + }, [composerOverlayElement, publishComposerOverlayHeightStable, showScrollToBottom]); // Swapping the composer for the status bar (or back) changes what the // overlay holds, so rebuild the reservation from the new content. useLayoutEffect(() => { @@ -7105,8 +6940,8 @@ export default function ChatView(props: ChatViewProps) { composerMountedRef.current = composerMounted; if (!composerOverlayElement) return; composerTimelineInsetRef.current = 0; - publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); - }, [composerMounted, composerOverlayElement, publishComposerOverlayHeight]); + publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height); + }, [composerMounted, composerOverlayElement, publishComposerOverlayHeightStable]); const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); const activeThreadReferenceCopyTarget = useMemo( () => @@ -7126,7 +6961,7 @@ export default function ChatView(props: ChatViewProps) { openPanelPullRequestUrl, ], ); - const copyActiveThreadReference = useCallback(() => { + const copyActiveThreadReference = () => { const target = activeThreadReferenceCopyTarget; if (target === null) return; void writeTextToClipboard(target.value, target.clipboardTarget).then( @@ -7149,7 +6984,8 @@ export default function ChatView(props: ChatViewProps) { ); }, ); - }, [activeThreadReferenceCopyTarget]); + }; + const copyActiveThreadReferenceStable = useStableCallback(copyActiveThreadReference); const pullRequestPanelTarget = activeThread ? threadPullRequestPanelTarget({ projectId: activeThread.projectId, @@ -7158,11 +6994,12 @@ export default function ChatView(props: ChatViewProps) { branchPullRequest: activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, }) : null; - const addPullRequestSurface = useCallback(() => { + const addPullRequestSurface = () => { if (!supportsPullRequests || activeThreadRef === null || pullRequestPanelTarget === null) return; useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget); - }, [activeThreadRef, pullRequestPanelTarget, supportsPullRequests]); + }; + const addPullRequestSurfaceStable = useStableCallback(addPullRequestSurface); const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; @@ -7189,10 +7026,11 @@ export default function ChatView(props: ChatViewProps) { ? threadWokeAt(activeThreadShell, { now: new Date().toISOString() }) : null; const acknowledgeThreadWoke = useAcknowledgeThreadWoke(); - const acknowledgeActiveThreadWoke = useCallback(() => { + const acknowledgeActiveThreadWoke = () => { if (activeThreadRef === null || activeThreadWokeAt === null) return; acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt); - }, [acknowledgeThreadWoke, activeThreadRef, activeThreadWokeAt]); + }; + const acknowledgeActiveThreadWokeStable = useStableCallback(acknowledgeActiveThreadWoke); // Mirror of the sidebar's Woke pill for the open thread. Same watermark as // the sidebar, so an acknowledgement from any device hides it. const activeThreadLocalVisitedAt = useUiStateStore((store) => @@ -7237,7 +7075,7 @@ export default function ChatView(props: ChatViewProps) { // must never clear (or re-enable) thread B's button. const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; - const handleUnsettleActiveThread = useCallback(async () => { + const handleUnsettleActiveThread = async () => { if ( !activeThreadRef || !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) @@ -7263,13 +7101,14 @@ export default function ChatView(props: ChatViewProps) { } finally { setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); } - }, [activeThreadRef, unsettleThreadMutation]); + }; + const handleUnsettleActiveThreadStable = useStableCallback(handleUnsettleActiveThread); const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, { reportFailure: false, }); const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; - const handleUnsnoozeActiveThread = useCallback(async () => { + const handleUnsnoozeActiveThread = async () => { if ( !activeThreadRef || !readEnvironmentScope(activeThreadRef.environmentId, AuthOrchestrationOperateScope) @@ -7309,7 +7148,8 @@ export default function ChatView(props: ChatViewProps) { } finally { setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); } - }, [activeThreadRef, activeThreadShell, unsnoozeThreadMutation, updateThreadMetadata]); + }; + const handleUnsnoozeActiveThreadStable = useStableCallback(handleUnsnoozeActiveThread); const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); // Once revealed for a given mismatch, the banner stays mounted until the @@ -7343,7 +7183,7 @@ export default function ChatView(props: ChatViewProps) { return revealed !== null && revealed !== activeBranchMismatchKey ? null : revealed; }); }, [activeBranchMismatchKey, showBranchMismatchBanner]); - const handleSwitchCheckoutToThread = useCallback(async () => { + const handleSwitchCheckoutToThread = async () => { if (!canWriteSourceControl) return; if ( !activeProjectCwd || @@ -7403,18 +7243,7 @@ export default function ChatView(props: ChatViewProps) { gitStatusQuery.refresh(); setIsRestoringThreadBranch(false); scheduleComposerFocus(); - }, [ - canWriteSourceControl, - activeProjectCwd, - activeThread, - environmentId, - gitStatusQuery, - isRestoringThreadBranch, - localCheckoutBranchMismatch, - scheduleComposerFocus, - switchGitRef, - updateThreadMetadata, - ]); + }; // The stack renders items[0] front-most and tucks the rest behind hover, so // ordering is priority: system banners, then the branch-mismatch notice, // and the informational parked-thread banner last — it must never cover another. @@ -7426,7 +7255,7 @@ export default function ChatView(props: ChatViewProps) { const [stoppingBackgroundWorkKey, setStoppingBackgroundWorkKey] = useState(null); const isStoppingBackgroundWork = stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`; - const handleStopBackgroundWork = useCallback(async () => { + const handleStopBackgroundWork = async () => { if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; const requestKey = `${environmentId}:${activeThread.id}`; @@ -7447,16 +7276,15 @@ export default function ChatView(props: ChatViewProps) { ); } } - }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); - const onOpenRelatedThread = useCallback( - (threadId: ThreadId) => { - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)), - }); - }, - [environmentId, navigate], - ); + }; + const handleStopBackgroundWorkStable = useStableCallback(handleStopBackgroundWork); + const onOpenRelatedThread = (threadId: ThreadId) => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)), + }); + }; + const onOpenRelatedThreadStable = useStableCallback(onOpenRelatedThread); const childThreadInputs = useChildThreadInputs(activeThreadRef); const childInputBannerItem = useMemo(() => { const first = childThreadInputs[0]; @@ -7473,7 +7301,7 @@ export default function ChatView(props: ChatViewProps) { {index > 0 ? " " : null} - onOpenRelatedThread(child.id)}> + onOpenRelatedThreadStable(child.id)}> {child.title} {index < childThreadInputs.length - 1 ? "," : null} @@ -7481,7 +7309,7 @@ export default function ChatView(props: ChatViewProps) { )), actions: ( - ), @@ -7489,95 +7317,78 @@ export default function ChatView(props: ChatViewProps) { }, [ childThreadInputs, activePendingApproval, - activePendingUserInput, - activeThreadShell?.hasPendingUserInput, - onOpenRelatedThread, - ]); - - // Commands such as /compact and /goal clear run as their own turn. The draft - // and its attachments stay local. - const sendStandaloneCommand = useCallback( - async (text: string, failureMessage: string) => { - if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return; - const context = composerRef.current?.getSendContext(); - if (!context?.providerAvailable) return; - - const threadId = activeThread.id; - const messageId = newMessageId(); - const createdAt = new Date().toISOString(); - sendInFlightRef.current = true; - beginLocalDispatch(); - setThreadError(threadId, null); - setOptimisticUserMessages((messages) => [ - ...messages, - { - id: messageId, - role: "user", - text, - runId: null, - createdAt, - updatedAt: createdAt, - streaming: false, - }, - ]); - scrollToEnd(); - try { - const settingsResult = await persistThreadSettingsForNextTurn({ - threadId, - createdAt, - ...(localCheckoutBranchMismatch - ? { branch: localCheckoutBranchMismatch.currentBranch } - : {}), - runtimeMode, - interactionMode: context.interactionMode, - }); - const result = - settingsResult._tag === "Failure" - ? settingsResult - : await startThreadTurn({ - environmentId, - input: { - threadId, - message: { messageId, role: "user", text, attachments: [] }, - modelSelection: context.selectedModelSelection, - runtimeMode, - interactionMode: context.interactionMode, - createdAt, - }, - }); - if (result._tag === "Failure") { - setOptimisticUserMessages((messages) => - messages.filter((message) => message.id !== messageId), - ); - resetLocalDispatch(); - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError(threadId, error instanceof Error ? error.message : failureMessage); - } - } else { - clearUsageLimitsFor(routeThreadKey); + activePendingUserInput, + activeThreadShell?.hasPendingUserInput, + onOpenRelatedThreadStable, + ]); + + // Commands such as /compact and /goal clear run as their own turn. The draft + // and its attachments stay local. + const sendStandaloneCommand = async (text: string, failureMessage: string) => { + if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return; + const context = composerRef.current?.getSendContext(); + if (!context?.providerAvailable) return; + + const threadId = activeThread.id; + const messageId = newMessageId(); + const createdAt = new Date().toISOString(); + sendInFlightRef.current = true; + beginLocalDispatch(); + setThreadError(threadId, null); + setOptimisticUserMessages((messages) => [ + ...messages, + { + id: messageId, + role: "user", + text, + runId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + ]); + scrollToEnd(); + try { + const settingsResult = await persistThreadSettingsForNextTurn({ + threadId, + createdAt, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), + runtimeMode, + interactionMode: context.interactionMode, + }); + const result = + settingsResult._tag === "Failure" + ? settingsResult + : await startThreadTurn({ + environmentId, + input: { + threadId, + message: { messageId, role: "user", text, attachments: [] }, + modelSelection: context.selectedModelSelection, + runtimeMode, + interactionMode: context.interactionMode, + createdAt, + }, + }); + if (result._tag === "Failure") { + setOptimisticUserMessages((messages) => + messages.filter((message) => message.id !== messageId), + ); + resetLocalDispatch(); + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError(threadId, error instanceof Error ? error.message : failureMessage); } - } finally { - sendInFlightRef.current = false; + } else { + clearUsageLimitsFor(routeThreadKey); } - }, - [ - activeThread, - beginLocalDispatch, - clientSettingsHydrated, - composerRef, - environmentId, - localCheckoutBranchMismatch, - persistThreadSettingsForNextTurn, - resetLocalDispatch, - routeThreadKey, - runtimeMode, - scrollToEnd, - sendInFlightRef, - setThreadError, - startThreadTurn, - ], - ); + } finally { + sendInFlightRef.current = false; + } + }; + const sendStandaloneCommandStable = useStableCallback(sendStandaloneCommand); // A native /goal keeps the agent working across turns. Stop pauses a Codex // goal; once the thread is idle the row offers the native follow-ups. const activeGoal = activeThreadShell?.goal ?? null; @@ -7601,7 +7412,9 @@ export default function ChatView(props: ChatViewProps) { @@ -7609,14 +7422,14 @@ export default function ChatView(props: ChatViewProps) { ), }; - }, [activeGoal, activeThread, isWorking, sendStandaloneCommand]); + }, [activeGoal, activeThread, isWorking, sendStandaloneCommandStable]); const backgroundWorkBannerItem = useMemo(() => { const presentation = presentPendingBackgroundWork(activeBackgroundTasks); @@ -7659,7 +7472,7 @@ export default function ChatView(props: ChatViewProps) { onOpenRelatedThread(childThreadId)} + onClick={() => onOpenRelatedThreadStable(childThreadId)} > {item.label} @@ -7674,7 +7487,7 @@ export default function ChatView(props: ChatViewProps) { size="xs" variant="ghost" disabled={!canOperateThread || isStoppingBackgroundWork} - onClick={() => void handleStopBackgroundWork()} + onClick={() => void handleStopBackgroundWorkStable()} > {isStoppingBackgroundWork ? "Stopping..." : "Stop"} @@ -7684,9 +7497,9 @@ export default function ChatView(props: ChatViewProps) { activeBackgroundTasks, activeThread, canOperateThread, - handleStopBackgroundWork, + handleStopBackgroundWorkStable, isStoppingBackgroundWork, - onOpenRelatedThread, + onOpenRelatedThreadStable, ]); // Settled, snoozed, and woke are thread state, not composer actions: each // gets one quiet line after the last message instead of a banner. A woken @@ -7707,7 +7520,7 @@ export default function ChatView(props: ChatViewProps) { } actionLabel={isUnsnoozing ? "Waking..." : "Wake now"} actionDisabled={!canOperateThread || isUnsnoozing} - onAction={() => void handleUnsnoozeActiveThread()} + onAction={() => void handleUnsnoozeActiveThreadStable()} /> ) : activeThreadSettled ? ( void handleUnsettleActiveThread()} + onAction={() => void handleUnsettleActiveThreadStable()} /> ) : activeThreadWokeVisible ? ( } label="Woke from snooze" actionLabel="Dismiss" - onAction={acknowledgeActiveThreadWoke} + onAction={acknowledgeActiveThreadWokeStable} /> ) : null; }, [ - acknowledgeActiveThreadWoke, + acknowledgeActiveThreadWokeStable, activeThreadSettled, activeThreadShell?.settledAt, activeThreadShell?.snoozedUntil, activeThreadSnoozed, activeThreadWokeVisible, canOperateThread, - handleUnsettleActiveThread, - handleUnsnoozeActiveThread, + handleUnsettleActiveThreadStable, + handleUnsnoozeActiveThreadStable, isUnsettling, isUnsnoozing, nowMinute, @@ -7815,7 +7628,7 @@ export default function ChatView(props: ChatViewProps) { () => new Set(), ); const keepFullHistory = fullHistoryThreadKeys.has(routeThreadKey); - const setKeepFullHistory = useCallback((threadKey: string, keep: boolean) => { + const setKeepFullHistory = (threadKey: string, keep: boolean) => { setFullHistoryThreadKeys((current) => { if (current.has(threadKey) === keep) return current; const next = new Set(current); @@ -7823,23 +7636,18 @@ export default function ChatView(props: ChatViewProps) { else next.delete(threadKey); return next; }); - }, []); - const toggleKeepFullHistory = useCallback( - () => setKeepFullHistory(routeThreadKey, !keepFullHistory), - [keepFullHistory, routeThreadKey, setKeepFullHistory], - ); - const handleRestoreThreadBranch = useCallback(() => { + }; + const toggleKeepFullHistory = () => setKeepFullHistory(routeThreadKey, !keepFullHistory); + const toggleKeepFullHistoryStable = useStableCallback(toggleKeepFullHistory); + const handleRestoreThreadBranch = () => { if (!canWriteSourceControl) return; if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); return; } void handleSwitchCheckoutToThread(); - }, [ - canWriteSourceControl, - gitStatusQuery.data?.hasWorkingTreeChanges, - handleSwitchCheckoutToThread, - ]); + }; + const handleRestoreThreadBranchStable = useStableCallback(handleRestoreThreadBranch); const feedbackBannerItems = useMemo( () => feedbackSubmissions.flatMap((submission) => { @@ -7929,7 +7737,7 @@ export default function ChatView(props: ChatViewProps) { size="xs" variant="ghost" disabled={!canWriteSourceControl || isRestoringThreadBranch} - onClick={handleRestoreThreadBranch} + onClick={handleRestoreThreadBranchStable} > {isRestoringThreadBranch ? "Restoring..." : "Restore branch"} @@ -7948,7 +7756,7 @@ export default function ChatView(props: ChatViewProps) { canWriteSourceControl, feedbackBannerItems, limitRecoveryBanner, - handleRestoreThreadBranch, + handleRestoreThreadBranchStable, isRestoringThreadBranch, backgroundWorkBannerItem, childInputBannerItem, @@ -8028,7 +7836,7 @@ export default function ChatView(props: ChatViewProps) { } else if (previous && !current) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; const frame = window.requestAnimationFrame(() => { - focusComposer(); + focusComposerStable(); }); return () => { window.cancelAnimationFrame(frame); @@ -8036,31 +7844,31 @@ export default function ChatView(props: ChatViewProps) { } terminalUiOpenByThreadRef.current[activeThreadKey] = current; - }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); - - const getShortcutContext = useCallback( - (eventTarget: EventTarget | null = document.activeElement) => ({ - terminalFocus: getTerminalFocusOwner() !== null, - terminalOpen: Boolean(terminalUiState.terminalOpen), - previewFocus: isPreviewFocused(), - previewOpen: previewPanelOpen, - editableFocus: isEditableFocused(eventTarget), - modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, - composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor", - draftThreadRoute: routeKind === "draft", - turnRunning: phase === "running", - isWeb: !isElectron, - isDesktop: isElectron, - }), - [composerRef, previewPanelOpen, terminalUiState.terminalOpen, routeKind, phase], - ); + }, [activeThreadKey, focusComposerStable, terminalUiState.terminalOpen]); + + const getShortcutContext = (eventTarget: EventTarget | null = document.activeElement) => ({ + terminalFocus: getTerminalFocusOwner() !== null, + terminalOpen: Boolean(terminalUiState.terminalOpen), + previewFocus: isPreviewFocused(), + previewOpen: previewPanelOpen, + editableFocus: isEditableFocused(eventTarget), + modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, + composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor", + draftThreadRoute: routeKind === "draft", + turnRunning: phase === "running", + isWeb: !isElectron, + isDesktop: isElectron, + }); + const getShortcutContextStable = useStableCallback(getShortcutContext); const timelineSkills = activeProviderStatus ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) : EMPTY_PROVIDER_SKILLS; const threadFindControlsRef = useRef(null); const [isThreadFindActive, setIsThreadFindActive] = useState(false); - const openThreadFind = useCallback(() => threadFindControlsRef.current?.open(), []); - const closeThreadFind = useCallback(() => threadFindControlsRef.current?.close(), []); + const openThreadFind = () => threadFindControlsRef.current?.open(); + const openThreadFindStable = useStableCallback(openThreadFind); + const closeThreadFind = () => threadFindControlsRef.current?.close(); + const closeThreadFindStable = useStableCallback(closeThreadFind); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -8081,7 +7889,7 @@ export default function ChatView(props: ChatViewProps) { if (event.defaultPrevented && terminalFocusOwner === null) { return; } - const shortcutContext = getShortcutContext(event.target); + const shortcutContext = getShortcutContextStable(event.target); if ( !shortcutContext.terminalFocus && @@ -8103,7 +7911,7 @@ export default function ChatView(props: ChatViewProps) { if (command === "thread.copyReference") { event.preventDefault(); event.stopPropagation(); - if (!event.repeat) copyActiveThreadReference(); + if (!event.repeat) copyActiveThreadReferenceStable(); return; } @@ -8113,7 +7921,7 @@ export default function ChatView(props: ChatViewProps) { event.stopPropagation(); if (!isServerThread || !activeThreadRef || !supportsSettlement) return; if (activeThreadSettled) { - void handleUnsettleActiveThread(); + void handleUnsettleActiveThreadStable(); return; } // Same as the sidebar Settle button: settle, then open the next thread. @@ -8159,7 +7967,7 @@ export default function ChatView(props: ChatViewProps) { if (!terminalUiState.terminalOpen && !canReadTerminal && !canOperateTerminal) return; event.preventDefault(); event.stopPropagation(); - toggleTerminalVisibility(); + toggleTerminalVisibilityStable(); return; } @@ -8167,28 +7975,28 @@ export default function ChatView(props: ChatViewProps) { if (command === "chat.find" && isServerThread && serverConfig?.threadFind === true) { event.preventDefault(); event.stopPropagation(); - openThreadFind(); + openThreadFindStable(); return; } if (command === "rightPanel.toggle") { event.preventDefault(); event.stopPropagation(); - toggleRightPanel(); + toggleRightPanelStable(); return; } if (command === "rightPanel.toggleMaximized") { event.preventDefault(); event.stopPropagation(); - if (!event.repeat) toggleRightPanelMaximized(); + if (!event.repeat) toggleRightPanelMaximizedStable(); return; } if (command === "threadPanel.toggle") { event.preventDefault(); event.stopPropagation(); - toggleThreadPanel(); + toggleThreadPanelStable(); return; } @@ -8198,7 +8006,7 @@ export default function ChatView(props: ChatViewProps) { if (!activeRightPanelSurface) return; event.preventDefault(); event.stopPropagation(); - if (!event.repeat) closeRightPanelSurface(activeRightPanelSurface); + if (!event.repeat) closeRightPanelSurfaceStable(activeRightPanelSurface); return; } @@ -8209,13 +8017,13 @@ export default function ChatView(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel") { - splitPanelTerminal(); + splitPanelTerminalStable(); return; } if (!terminalUiState.terminalOpen) { - setTerminalOpen(true); + setTerminalOpenStable(true); } - splitTerminal(); + splitTerminalStable(); return; } @@ -8226,13 +8034,13 @@ export default function ChatView(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel") { - splitPanelTerminal("vertical"); + splitPanelTerminalStable("vertical"); return; } if (!terminalUiState.terminalOpen) { - setTerminalOpen(true); + setTerminalOpenStable(true); } - splitTerminal("vertical"); + splitTerminalStable("vertical"); return; } @@ -8243,11 +8051,11 @@ export default function ChatView(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { - requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId); + requestClosePanelTerminalStable(activeRightPanelSurface.activeTerminalId); return; } if (!terminalUiState.terminalOpen) return; - requestCloseTerminal(terminalUiState.activeTerminalId); + requestCloseTerminalStable(terminalUiState.activeTerminalId); return; } @@ -8258,20 +8066,20 @@ export default function ChatView(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel") { - addTerminalSurface(); + addTerminalSurfaceStable(); return; } if (!terminalUiState.terminalOpen) { - setTerminalOpen(true); + setTerminalOpenStable(true); } - createNewTerminal(); + createNewTerminalStable(); return; } if (command === "diff.toggle") { event.preventDefault(); event.stopPropagation(); - onToggleDiff(); + onToggleDiffStable(); return; } @@ -8305,7 +8113,7 @@ export default function ChatView(props: ChatViewProps) { (env) => env.environmentId === currentId, ); const next = logicalProjectEnvironments[(index + 1) % logicalProjectEnvironments.length]; - if (next) onEnvironmentChange(next.environmentId); + if (next) onEnvironmentChangeStable(next.environmentId); return; } @@ -8348,7 +8156,7 @@ export default function ChatView(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (event.repeat) return; - void onInterrupt(); + void onInterruptStable(); return; } @@ -8358,7 +8166,7 @@ export default function ChatView(props: ChatViewProps) { if (!script) return; event.preventDefault(); event.stopPropagation(); - void runProjectScript(script); + void runProjectScriptStable(script); }; const dismissFind = (event: KeyboardEvent) => { if ( @@ -8370,11 +8178,11 @@ export default function ChatView(props: ChatViewProps) { isCommandPaletteOpen() ) return; - const context = getShortcutContext(event.target); + const context = getShortcutContextStable(event.target); if (context.terminalFocus || context.previewFocus || context.modelPickerOpen) return; event.preventDefault(); - closeThreadFind(); - focusComposer(); + closeThreadFindStable(); + focusComposerStable(); }; window.addEventListener("keydown", handler, true); window.addEventListener("keydown", dismissFind); @@ -8388,7 +8196,7 @@ export default function ChatView(props: ChatViewProps) { activeProjectScripts, canReadTerminal, canOperateTerminal, - addTerminalSurface, + addTerminalSurfaceStable, activeThreadRef, activeThreadPinned, activeThreadSettled, @@ -8396,51 +8204,51 @@ export default function ChatView(props: ChatViewProps) { terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, - closeRightPanelSurface, - requestCloseTerminal, - requestClosePanelTerminal, - createNewTerminal, - setTerminalOpen, - runProjectScript, - splitTerminal, - splitPanelTerminal, + closeRightPanelSurfaceStable, + requestCloseTerminalStable, + requestClosePanelTerminalStable, + createNewTerminalStable, + setTerminalOpenStable, + runProjectScriptStable, + splitTerminalStable, + splitPanelTerminalStable, keybindings, scriptKeybindings, - handleUnsettleActiveThread, + handleUnsettleActiveThreadStable, isServerThread, serverConfig?.threadFind, - onInterrupt, - onToggleDiff, + onInterruptStable, + onToggleDiffStable, pinThread, settleThread, supportsPinning, supportsSettlement, confirmAndUnpinThread, - copyActiveThreadReference, - getShortcutContext, - openThreadFind, - closeThreadFind, + copyActiveThreadReferenceStable, + getShortcutContextStable, + openThreadFindStable, + closeThreadFindStable, isThreadFindActive, - toggleRightPanel, - toggleRightPanelMaximized, - toggleThreadPanel, - toggleTerminalVisibility, + toggleRightPanelStable, + toggleRightPanelMaximizedStable, + toggleThreadPanelStable, + toggleTerminalVisibilityStable, composerRef, draftId, environmentId, envLocked, hasMultipleEnvironments, logicalProjectEnvironments, - onEnvironmentChange, + onEnvironmentChangeStable, ]); // A focused desktop browser page forwards these chords as menu actions. useEffect(() => { return window.desktopBridge?.onMenuAction((action) => { - if (action === "rightPanel.toggle") toggleRightPanel(); - else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximized(); + if (action === "rightPanel.toggle") toggleRightPanelStable(); + else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximizedStable(); }); - }, [toggleRightPanel, toggleRightPanelMaximized]); + }, [toggleRightPanelStable, toggleRightPanelMaximizedStable]); // Paste-to-focus: the resting composer blurs on a click into the timeline, // so a paste that follows has no editable target and would be dropped. @@ -8490,266 +8298,233 @@ export default function ChatView(props: ChatViewProps) { setPendingRevert(null); } - const onRevertToTurnCount = useCallback( - async (turnCount: number, messageId: MessageId, restoreFiles?: boolean) => { - const localApi = readLocalApi(); - if ( - !localApi || - !activeThread || - isRevertingCheckpoint || - !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) - ) - return; - const sourceMessage = serverProjection?.messages.find((message) => message.id === messageId); - const message = sourceMessage - ? { - id: sourceMessage.id, - role: sourceMessage.role, - text: sourceMessage.text, - attachments: sourceMessage.attachments, - context: sourceMessage.context, - runId: sourceMessage.runId, - streaming: false, - createdAt: DateTime.formatIso(sourceMessage.createdAt), - updatedAt: DateTime.formatIso(sourceMessage.updatedAt), - } - : undefined; - if (!message || message.role !== "user") return; + const onRevertToTurnCount = async ( + turnCount: number, + messageId: MessageId, + restoreFiles?: boolean, + ) => { + const localApi = readLocalApi(); + if ( + !localApi || + !activeThread || + isRevertingCheckpoint || + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) + ) + return; + const sourceMessage = serverProjection?.messages.find((message) => message.id === messageId); + const message = sourceMessage + ? { + id: sourceMessage.id, + role: sourceMessage.role, + text: sourceMessage.text, + attachments: sourceMessage.attachments, + context: sourceMessage.context, + runId: sourceMessage.runId, + streaming: false, + createdAt: DateTime.formatIso(sourceMessage.createdAt), + updatedAt: DateTime.formatIso(sourceMessage.updatedAt), + } + : undefined; + if (!message || message.role !== "user") return; - if (!supportsConversationRollback) { - setThreadError( - activeThread.id, - "This provider does not support reverting conversation history. Start a new thread instead.", - ); - return; + if (!supportsConversationRollback) { + setThreadError( + activeThread.id, + "This provider does not support reverting conversation history. Start a new thread instead.", + ); + return; + } + if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { + setThreadError( + activeThread.id, + `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, + ); + return; + } + if (phase === "running" || isSendBusy || isConnecting) { + setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); + return; + } + if (restoreFiles === undefined) { + setPendingRevert({ turnCount, messageId, routeThreadKey }); + return; + } + + useComposerDraftStore.setState((store) => ({ + rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey), + })); + setThreadError(activeThread.id, null); + try { + if (composerRef.current?.hasPendingAttachments()) { + throw new Error("Wait for attachments to finish preparing before rewinding."); } - if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { - setThreadError( - activeThread.id, - `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error("The environment is not connected."); + const files = await prepareRevertedMessageAttachments({ + message, + environmentId, + httpBaseUrl: connection.httpBaseUrl, + createAssetUrl: createAttachmentAssetUrl, + }); + const store = useComposerDraftStore.getState(); + const draft = store.getComposerDraft(composerDraftTarget); + if ( + (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length > + PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) { + throw new Error( + "Make room for this message's attachments in the composer before rewinding.", ); - return; - } - if (phase === "running" || isSendBusy || isConnecting) { - setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); - return; - } - if (restoreFiles === undefined) { - setPendingRevert({ turnCount, messageId, routeThreadKey }); - return; } - - useComposerDraftStore.setState((store) => ({ - rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey), - })); - setThreadError(activeThread.id, null); - try { - if (composerRef.current?.hasPendingAttachments()) { - throw new Error("Wait for attachments to finish preparing before rewinding."); - } - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error("The environment is not connected."); - const files = await prepareRevertedMessageAttachments({ - message, + const commandId = CommandId.make(randomUUID()); + await waitForRevertedMessage(routeThreadRef, messageId, turnCount, commandId, async () => { + const result = await revertThreadCheckpoint({ environmentId, - httpBaseUrl: connection.httpBaseUrl, - createAssetUrl: createAttachmentAssetUrl, - }); - const store = useComposerDraftStore.getState(); - const draft = store.getComposerDraft(composerDraftTarget); - if ( - (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length > - PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { - throw new Error( - "Make room for this message's attachments in the composer before rewinding.", - ); - } - const commandId = CommandId.make(randomUUID()); - await waitForRevertedMessage(routeThreadRef, messageId, turnCount, commandId, async () => { - const result = await revertThreadCheckpoint({ - environmentId, - input: { commandId, threadId: activeThread.id, turnCount, restoreFiles }, - }); - if (result._tag === "Failure") throw squashAtomCommandFailure(result); + input: { commandId, threadId: activeThread.id, turnCount, restoreFiles }, }); - const currentPrompt = store.getComposerDraft(composerDraftTarget)?.prompt ?? ""; - const restoredPrompt = recallableComposerPrompt(message.text); - const nextPrompt = - restoredPrompt.length === 0 - ? currentPrompt - : currentPrompt.length > 0 - ? `${currentPrompt}\n\n${restoredPrompt}` - : restoredPrompt; - store.setPrompt(composerDraftTarget, nextPrompt); - const images: ComposerImageAttachment[] = []; - const restoredFiles: ComposerFileAttachment[] = []; - files.forEach((file, index) => { - const attachment = { - id: randomUUID(), - name: file.name, - mimeType: file.type, - sizeBytes: file.size, - file, - }; - if (message.attachments?.[index]?.type === "image") { - images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); - } else { - restoredFiles.push({ ...attachment, type: "file" }); - } - }); - store.addImages(composerDraftTarget, images, { allowDuplicates: true }); - store.addFiles(composerDraftTarget, restoredFiles, { allowDuplicates: true }); - if (currentRouteThreadKeyRef.current === routeThreadKey) { - promptRef.current = nextPrompt; - composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length }); - requestAnimationFrame(() => { - if (currentRouteThreadKeyRef.current === routeThreadKey) - composerRef.current?.focusAtEnd(); - }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + }); + const currentPrompt = store.getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const restoredPrompt = recallableComposerPrompt(message.text); + const nextPrompt = + restoredPrompt.length === 0 + ? currentPrompt + : currentPrompt.length > 0 + ? `${currentPrompt}\n\n${restoredPrompt}` + : restoredPrompt; + store.setPrompt(composerDraftTarget, nextPrompt); + const images: ComposerImageAttachment[] = []; + const restoredFiles: ComposerFileAttachment[] = []; + files.forEach((file, index) => { + const attachment = { + id: randomUUID(), + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }; + if (message.attachments?.[index]?.type === "image") { + images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); + } else { + restoredFiles.push({ ...attachment, type: "file" }); } - } catch (error) { - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to revert thread state.", - ); - } finally { - useComposerDraftStore.setState((store) => { - const remaining = new Set(store.rewindingThreadKeys); - remaining.delete(routeThreadKey); - return { rewindingThreadKeys: remaining }; - }); - } - }, - [ - activeThread, - activeEnvironmentUnavailable, - activeEnvironmentUnavailableLabel, - composerDraftTarget, - composerRef, - createAttachmentAssetUrl, - environmentId, - isConnecting, - isRevertingCheckpoint, - isSendBusy, - phase, - revertThreadCheckpoint, - routeThreadKey, - routeThreadRef, - setThreadError, - supportsConversationRollback, - serverProjection, - ], - ); - - const onRollbackCheckpoint = useCallback( - async (input: { readonly checkpointId: string; readonly scopeId: string }) => { - if (!activeThread || isRevertingCheckpoint) return; - if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { - setThreadError( - activeThread.id, - `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, - ); - return; - } - if (phase === "running" || isSendBusy || isConnecting) { - setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); - return; - } - const localApi = readLocalApi(); - const confirmed = - localApi == null - ? window.confirm("Roll back this thread to the selected checkpoint?") - : await localApi.dialogs.confirm( - "Roll back this thread to the selected checkpoint?\nThis action cannot be undone.", - ); - if (!confirmed) return; - - useComposerDraftStore.setState((store) => ({ - rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey), - })); - setThreadError(activeThread.id, null); - const result = await revertThreadCheckpoint({ - environmentId, - input: { - threadId: activeThread.id, - checkpointId: input.checkpointId, - scopeId: input.scopeId, - }, }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to revert thread state.", - ); + store.addImages(composerDraftTarget, images, { allowDuplicates: true }); + store.addFiles(composerDraftTarget, restoredFiles, { allowDuplicates: true }); + if (currentRouteThreadKeyRef.current === routeThreadKey) { + promptRef.current = nextPrompt; + composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length }); + requestAnimationFrame(() => { + if (currentRouteThreadKeyRef.current === routeThreadKey) + composerRef.current?.focusAtEnd(); + }); } + } catch (error) { + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to revert thread state.", + ); + } finally { useComposerDraftStore.setState((store) => { const remaining = new Set(store.rewindingThreadKeys); remaining.delete(routeThreadKey); return { rewindingThreadKeys: remaining }; }); - }, - [ - activeEnvironmentUnavailable, - activeEnvironmentUnavailableLabel, - activeThread, + } + }; + const onRevertToTurnCountStable = useStableCallback(onRevertToTurnCount); + + const onRollbackCheckpoint = async (input: { + readonly checkpointId: string; + readonly scopeId: string; + }) => { + if (!activeThread || isRevertingCheckpoint) return; + if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { + setThreadError( + activeThread.id, + `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, + ); + return; + } + if (phase === "running" || isSendBusy || isConnecting) { + setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); + return; + } + const localApi = readLocalApi(); + const confirmed = + localApi == null + ? window.confirm("Roll back this thread to the selected checkpoint?") + : await localApi.dialogs.confirm( + "Roll back this thread to the selected checkpoint?\nThis action cannot be undone.", + ); + if (!confirmed) return; + + useComposerDraftStore.setState((store) => ({ + rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey), + })); + setThreadError(activeThread.id, null); + const result = await revertThreadCheckpoint({ environmentId, - isConnecting, - isRevertingCheckpoint, - isSendBusy, - phase, - revertThreadCheckpoint, - setThreadError, - ], - ); + input: { + threadId: activeThread.id, + checkpointId: input.checkpointId, + scopeId: input.scopeId, + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to revert thread state.", + ); + } + useComposerDraftStore.setState((store) => { + const remaining = new Set(store.rewindingThreadKeys); + remaining.delete(routeThreadKey); + return { rewindingThreadKeys: remaining }; + }); + }; - const onForkFromRun = useCallback( - async (input: { readonly sourceThreadId: ThreadId; readonly runId: RunId }) => { - if (!activeThread || activeEnvironmentUnavailable) return; - const targetThreadId = newThreadId(); - const targetThreadRef = scopeThreadRef(environmentId, targetThreadId); - const result = await forkThreadFromRun({ - environmentId, - input: { - sourceThreadId: input.sourceThreadId, - targetThreadId, - runId: input.runId, - title: `${activeThread.title} fork`, - }, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to fork this response.", - ); - } - return; - } - const targetThreadReady = await waitForThreadShell(targetThreadRef); - if (!targetThreadReady) { + const onForkFromRun = async (input: { + readonly sourceThreadId: ThreadId; + readonly runId: RunId; + }) => { + if (!activeThread || activeEnvironmentUnavailable) return; + const targetThreadId = newThreadId(); + const targetThreadRef = scopeThreadRef(environmentId, targetThreadId); + const result = await forkThreadFromRun({ + environmentId, + input: { + sourceThreadId: input.sourceThreadId, + targetThreadId, + runId: input.runId, + title: `${activeThread.title} fork`, + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); setThreadError( activeThread.id, - "The fork was created, but its thread data did not reach this client. Reconnect and try opening it from the sidebar.", + error instanceof Error ? error.message : "Failed to fork this response.", ); - return; } - await navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(targetThreadRef), - }); - }, - [ - activeEnvironmentUnavailable, - activeThread, - environmentId, - forkThreadFromRun, - navigate, - setThreadError, - ], - ); + return; + } + const targetThreadReady = await waitForThreadShell(targetThreadRef); + if (!targetThreadReady) { + setThreadError( + activeThread.id, + "The fork was created, but its thread data did not reach this client. Reconnect and try opening it from the sidebar.", + ); + return; + } + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(targetThreadRef), + }); + }; + const onForkFromRunStable = useStableCallback(onForkFromRun); const onCompactContext = () => { if (compactDisabled) return; void sendStandaloneCommand("/compact", "Failed to compact context."); @@ -10225,245 +10000,214 @@ export default function ChatView(props: ChatViewProps) { } }; - const onRespondToApproval = useCallback( - async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => { - if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) - return; - if ( - pendingApprovals.find((approval) => approval.requestId === requestId) - ?.responseCapability !== "live" - ) - return; + const onRespondToApproval = async ( + requestId: RuntimeRequestId, + decision: ProviderApprovalDecision, + ) => { + if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + return; + if ( + pendingApprovals.find((approval) => approval.requestId === requestId)?.responseCapability !== + "live" + ) + return; - setRespondingRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], + setRespondingRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + const result = await respondToThreadApproval({ + environmentId, + input: { + threadId: activeThreadId, + requestId, + decision, + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : "Failed to submit approval decision.", ); - const result = await respondToThreadApproval({ + } + setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); + return result; + }; + const onRespondToApprovalStable = useStableCallback(onRespondToApproval); + + const onRespondToUserInput = async ( + requestId: RuntimeRequestId, + answers: Record, + ) => { + if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) + return; + const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId); + if (!pendingInput || pendingInput.responseCapability === "not_resumable") return; + const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]); + if (userInputResponsesInFlight.current.has(responseKey)) return; + const attachmentsByQuestionId = new Map< + string, + import("@t3tools/contracts").UserInputAttachments[string] + >(); + const providerAnswers = { ...answers }; + for (const question of pendingInput.questions) { + const target = questionAttachmentDraftId( environmentId, - input: { - threadId: activeThreadId, - requestId, - decision, - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); + activeThreadId, + requestId, + question.id, + ); + if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return; + const draft = useComposerDraftStore.getState().getComposerDraft(target); + providerAnswers[question.id] = projectPendingUserInputAnswer( + answers[question.id], + draft?.threadContexts ?? [], + ); + const attachments = draft ? [...draft.images, ...draft.files] : []; + if (attachments.length === 0) continue; + const uploaded = getUploadedAttachments({ environmentId, images: attachments }); + if (!uploaded) { setThreadError( activeThreadId, - error instanceof Error ? error.message : "Failed to submit approval decision.", + "Wait for attachments to finish uploading, or remove failed uploads.", ); - } - setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); - return result; - }, - [activeThreadId, environmentId, pendingApprovals, respondToThreadApproval, setThreadError], - ); - - const onRespondToUserInput = useCallback( - async (requestId: RuntimeRequestId, answers: Record) => { - if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return; - const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId); - if (!pendingInput || pendingInput.responseCapability === "not_resumable") return; - const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]); - if (userInputResponsesInFlight.current.has(responseKey)) return; - const attachmentsByQuestionId = new Map< - string, - import("@t3tools/contracts").UserInputAttachments[string] - >(); - const providerAnswers = { ...answers }; - for (const question of pendingInput.questions) { - const target = questionAttachmentDraftId( - environmentId, - activeThreadId, - requestId, - question.id, - ); - if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return; - const draft = useComposerDraftStore.getState().getComposerDraft(target); - providerAnswers[question.id] = projectPendingUserInputAnswer( - answers[question.id], - draft?.threadContexts ?? [], - ); - const attachments = draft ? [...draft.images, ...draft.files] : []; - if (attachments.length === 0) continue; - const uploaded = getUploadedAttachments({ environmentId, images: attachments }); - if (!uploaded) { - setThreadError( - activeThreadId, - "Wait for attachments to finish uploading, or remove failed uploads.", - ); - return; - } - attachmentsByQuestionId.set( - question.id, - uploaded as import("@t3tools/contracts").UserInputAttachments[string], - ); } - userInputResponsesInFlight.current.add(responseKey); + attachmentsByQuestionId.set( + question.id, + uploaded as import("@t3tools/contracts").UserInputAttachments[string], + ); + } + userInputResponsesInFlight.current.add(responseKey); - setRespondingUserInputRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], + setRespondingUserInputRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + const result = await respondToThreadUserInput({ + environmentId, + input: { + threadId: activeThreadId, + requestId, + answers: providerAnswers, + ...(attachmentsByQuestionId.size > 0 + ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) } + : {}), + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : "Failed to submit user input.", ); - const result = await respondToThreadUserInput({ - environmentId, - input: { - threadId: activeThreadId, - requestId, - answers: providerAnswers, - ...(attachmentsByQuestionId.size > 0 - ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) } - : {}), - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : "Failed to submit user input.", - ); - } - userInputResponsesInFlight.current.delete(responseKey); - setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); - return result; - }, - [activeThreadId, environmentId, pendingUserInputs, respondToThreadUserInput, setThreadError], - ); + } + userInputResponsesInFlight.current.delete(responseKey); + setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + return result; + }; // Closes an async question without messaging the agent. The server records // the dismissal so every client releases the composer. - const onDismissUserInput = useCallback( - async (requestId: RuntimeRequestId) => { - if (!activeThreadId) return; + const onDismissUserInput = async (requestId: RuntimeRequestId) => { + if (!activeThreadId) return; - setRespondingUserInputRequestIds((existing) => - existing.includes(requestId) ? existing : [...existing, requestId], + setRespondingUserInputRequestIds((existing) => + existing.includes(requestId) ? existing : [...existing, requestId], + ); + const result = await dismissThreadUserInput({ + environmentId, + input: { threadId: activeThreadId, requestId }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : "Failed to dismiss the question.", ); - const result = await dismissThreadUserInput({ - environmentId, - input: { threadId: activeThreadId, requestId }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : "Failed to dismiss the question.", - ); - } - setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); - return result; - }, - [activeThreadId, dismissThreadUserInput, environmentId, setThreadError], - ); + } + setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); + return result; + }; + const onDismissUserInputStable = useStableCallback(onDismissUserInput); - const setActivePendingUserInputQuestionIndex = useCallback( - (nextQuestionIndex: number) => { - if (!activePendingUserInput) { - return; - } - setPendingUserInputQuestionIndexByRequestId((existing) => ({ - ...existing, - [activePendingRequestKey]: nextQuestionIndex, - })); - }, - [activePendingUserInput, activePendingRequestKey], - ); + const setActivePendingUserInputQuestionIndex = (nextQuestionIndex: number) => { + if (!activePendingUserInput) { + return; + } + setPendingUserInputQuestionIndexByRequestId((existing) => ({ + ...existing, + [activePendingRequestKey]: nextQuestionIndex, + })); + }; - const onSelectActivePendingUserInputOption = useCallback( - (questionId: string, optionValue: string) => { - if (!activePendingUserInput) { - return; + const onSelectActivePendingUserInputOption = (questionId: string, optionValue: string) => { + if (!activePendingUserInput) { + return; + } + // The option replaces the custom answer. Anything typed there is the + // user's text, so it goes back to the thread draft instead of vanishing. + const displacedAnswer = + pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; + const currentPrompt = + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); + if (nextPrompt !== currentPrompt) { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + if (activeThreadId) { + const store = useComposerDraftStore.getState(); + const questionTarget = questionAttachmentDraftId( + environmentId, + activeThreadId, + activePendingUserInput.requestId, + questionId, + ); + store.addThreadContexts( + composerDraftTarget, + store.getComposerDraft(questionTarget)?.threadContexts ?? [], + { appendReference: false }, + ); } - // The option replaces the custom answer. Anything typed there is the - // user's text, so it goes back to the thread draft instead of vanishing. - const displacedAnswer = - pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer; - const currentPrompt = - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; - const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); - if (nextPrompt !== currentPrompt) { - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - if (activeThreadId) { - const store = useComposerDraftStore.getState(); - const questionTarget = questionAttachmentDraftId( - environmentId, - activeThreadId, - activePendingUserInput.requestId, - questionId, - ); - store.addThreadContexts( - composerDraftTarget, - store.getComposerDraft(questionTarget)?.threadContexts ?? [], - { appendReference: false }, - ); - } + } + setPendingUserInputAnswersByRequestId((existing) => { + const question = + (activePendingProgress?.activeQuestion?.id === questionId + ? activePendingProgress.activeQuestion + : undefined) ?? activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question) { + return existing; } - setPendingUserInputAnswersByRequestId((existing) => { - const question = - (activePendingProgress?.activeQuestion?.id === questionId - ? activePendingProgress.activeQuestion - : undefined) ?? - activePendingUserInput.questions.find((entry) => entry.id === questionId); - if (!question) { - return existing; - } - - return { - ...existing, - [activePendingRequestKey]: { - ...existing[activePendingRequestKey], - [questionId]: togglePendingUserInputOptionSelection( - question, - existing[activePendingRequestKey]?.[questionId], - optionValue, - ), - }, - }; - }); - promptRef.current = ""; - composerRef.current?.resetCursorState({ cursor: 0 }); - }, - [ - activePendingProgress?.activeQuestion, - activePendingUserInput, - activePendingRequestKey, - activeThreadId, - composerDraftTarget, - composerRef, - environmentId, - pendingUserInputAnswersByRequestId, - setComposerDraftPrompt, - ], - ); - const onChangeActivePendingUserInputCustomAnswer = useCallback( - (questionId: string, value: string) => { - if (!activePendingUserInput) { - return; - } - const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); - if (!question || question.allowCustomAnswer === false) { - return; - } - promptRef.current = value; - setPendingUserInputAnswersByRequestId((existing) => ({ + return { ...existing, [activePendingRequestKey]: { ...existing[activePendingRequestKey], - [questionId]: setPendingUserInputCustomAnswer( - existing[activePendingRequestKey]?.[questionId], - value, + [questionId]: togglePendingUserInputOptionSelection( question, + existing[activePendingRequestKey]?.[questionId], + optionValue, ), }, - })); - // The editor owns this selection. Resetting it before the answer renders - // can re-enter Lexical with offsets from the previous skill token. - }, + }; + }); + promptRef.current = ""; + composerRef.current?.resetCursorState({ cursor: 0 }); + }; + const onSelectActivePendingUserInputOptionStable = useStableCallback( + onSelectActivePendingUserInputOption, + ); + + const onChangeActivePendingUserInputCustomAnswer = useMemo( + () => + makePendingCustomAnswerSetter({ + activePendingUserInput, + activePendingRequestKey, + promptRef, + setPendingUserInputAnswersByRequestId, + }), [activePendingUserInput, activePendingRequestKey], ); - const onAdvanceActivePendingUserInput = useCallback(() => { + const onAdvanceActivePendingUserInput = () => { if ( !activePendingUserInput || activePendingUserInput.responseCapability === "not_resumable" || @@ -10478,21 +10222,18 @@ export default function ChatView(props: ChatViewProps) { return; } setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); - }, [ - activePendingProgress, - activePendingResolvedAnswers, - activePendingUserInput, - activePendingIsResponding, - onRespondToUserInput, - setActivePendingUserInputQuestionIndex, - ]); + }; + const onAdvanceActivePendingUserInputStable = useStableCallback(onAdvanceActivePendingUserInput); - const onPreviousActivePendingUserInputQuestion = useCallback(() => { + const onPreviousActivePendingUserInputQuestion = () => { if (!activePendingProgress) { return; } setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); - }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); + }; + const onPreviousActivePendingUserInputQuestionStable = useStableCallback( + onPreviousActivePendingUserInputQuestion, + ); async function onSubmitPlanFollowUp({ text, @@ -10642,7 +10383,7 @@ export default function ChatView(props: ChatViewProps) { return false; } - const onImplementPlanInNewThread = useCallback(async () => { + const onImplementPlanInNewThread = async () => { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || !activeThread || @@ -10780,169 +10521,128 @@ export default function ChatView(props: ChatViewProps) { } } finish(); - }, [ - activeProject, - activeProposedPlan, - activeThreadBranch, - activeThread, - beginLocalDispatch, - activeEnvironmentUnavailable, - createThread, - deleteThread, - isConnecting, - isSendBusy, - isServerThread, - navigate, - resetLocalDispatch, - defaultRuntimeMode, - startThreadTurn, - environmentId, - composerRef, - ]); + }; + const onImplementPlanInNewThreadStable = useStableCallback(onImplementPlanInNewThread); - const getModelDisabledReason = useCallback( - (instanceId: ProviderInstanceId, model: string): string | null => { - if (!activeThread) { - return null; - } - const reason = getStartedThreadModelChangeBlockReason({ - providers: providerStatuses, - hasStartedSession: activeRuntime !== null, - supportsProviderSwitchingViaHandoff, - currentModelSelection: activeThread.modelSelection, - currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null, - nextModelSelection: { instanceId, model }, - }); - return reason ? `${reason.description} Start a new thread to use this model.` : null; - }, + const getModelDisabledReason = useMemo( + () => + makeModelDisabledReason( + activeThread + ? { + providers: providerStatuses, + hasStartedSession: activeRuntime !== null, + supportsProviderSwitchingViaHandoff, + currentModelSelection: activeThread.modelSelection, + currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null, + } + : null, + ), [activeRuntime, activeThread, providerStatuses, supportsProviderSwitchingViaHandoff], ); - const onProviderModelSelect = useCallback( - (instanceId: ProviderInstanceId, model: string, options?: { focusComposer?: boolean }) => { - if (!activeThread) return; - // Look up the configured instance so model normalization and custom - // model lookup stay scoped to that exact instance. Unknown instance ids - // are rejected by returning early; the server remains authoritative too. - const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); - const resolvedDriverKind = entry?.driver ?? null; - if ( - !supportsProviderSwitchingViaHandoff && - lockedProvider !== null && - resolvedDriverKind !== null && - resolvedDriverKind !== lockedProvider - ) { - if (options?.focusComposer !== false) scheduleComposerFocus(); - return; - } + const onProviderModelSelect = ( + instanceId: ProviderInstanceId, + model: string, + options?: { focusComposer?: boolean }, + ) => { + if (!activeThread) return; + // Look up the configured instance so model normalization and custom + // model lookup stay scoped to that exact instance. Unknown instance ids + // are rejected by returning early; the server remains authoritative too. + const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); + const resolvedDriverKind = entry?.driver ?? null; + if ( + !supportsProviderSwitchingViaHandoff && + lockedProvider !== null && + resolvedDriverKind !== null && + resolvedDriverKind !== lockedProvider + ) { + if (options?.focusComposer !== false) scheduleComposerFocus(); + return; + } + if ( + !supportsProviderSwitchingViaHandoff && + lockedProvider !== null && + activeRuntime?.providerInstanceId + ) { + const currentEntry = providerStatuses.find( + (snapshot) => snapshot.instanceId === activeRuntime.providerInstanceId, + ); if ( - !supportsProviderSwitchingViaHandoff && - lockedProvider !== null && - activeRuntime?.providerInstanceId + currentEntry?.continuation?.groupKey && + entry?.continuation?.groupKey && + currentEntry.continuation.groupKey !== entry.continuation.groupKey ) { - const currentEntry = providerStatuses.find( - (snapshot) => snapshot.instanceId === activeRuntime.providerInstanceId, - ); - if ( - currentEntry?.continuation?.groupKey && - entry?.continuation?.groupKey && - currentEntry.continuation.groupKey !== entry.continuation.groupKey - ) { - if (options?.focusComposer !== false) scheduleComposerFocus(); - return; - } - } - const resolvedModel = resolveAppModelSelectionForInstance( - instanceId, - settings, - providerStatuses, - model, - ); - if (!resolvedModel) { - if (options?.focusComposer !== false) scheduleComposerFocus(); - return; - } - // Restore this model's own remembered options; without any, start it - // from its default rather than carrying the previous model's over. - const rememberedOptions = - useComposerDraftStore.getState().stickyOptionsByModelByProvider[instanceId]?.[ - resolvedModel - ]; - const nextModelSelection: ModelSelection = - rememberedOptions !== undefined && rememberedOptions.length > 0 - ? { instanceId, model: resolvedModel, options: [...rememberedOptions] } - : { instanceId, model: resolvedModel }; - const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ - providers: providerStatuses, - hasStartedSession: activeRuntime !== null, - supportsProviderSwitchingViaHandoff, - currentModelSelection: activeThread.modelSelection, - currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null, - nextModelSelection, - }); - if (modelChangeBlockReason) { - toastManager.add({ - type: "warning", - title: modelChangeBlockReason.title, - description: modelChangeBlockReason.description, - }); if (options?.focusComposer !== false) scheduleComposerFocus(); return; } - setComposerDraftModelSelection( - scopeThreadRef(activeThread.environmentId, activeThread.id), - nextModelSelection, - // A complete snapshot: an absent options field means "start from the - // model default", not "keep the previous model's options". - { explicit: true, replaceOptions: true }, - ); - setStickyComposerModelSelection(nextModelSelection); + } + const resolvedModel = resolveAppModelSelectionForInstance( + instanceId, + settings, + providerStatuses, + model, + ); + if (!resolvedModel) { if (options?.focusComposer !== false) scheduleComposerFocus(); - }, - [ - activeThread, - activeRuntime, - lockedProvider, + return; + } + // Restore this model's own remembered options; without any, start it + // from its default rather than carrying the previous model's over. + const rememberedOptions = + useComposerDraftStore.getState().stickyOptionsByModelByProvider[instanceId]?.[resolvedModel]; + const nextModelSelection: ModelSelection = + rememberedOptions !== undefined && rememberedOptions.length > 0 + ? { instanceId, model: resolvedModel, options: [...rememberedOptions] } + : { instanceId, model: resolvedModel }; + const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ + providers: providerStatuses, + hasStartedSession: activeRuntime !== null, supportsProviderSwitchingViaHandoff, - scheduleComposerFocus, - setComposerDraftModelSelection, - setStickyComposerModelSelection, - providerStatuses, - settings, - ], - ); - const onEnvModeChange = useCallback( - (mode: DraftThreadEnvMode) => { - if (multipleModelSelections !== null) return; - if (canOverrideServerThreadEnvMode) { - setPendingServerThreadEnvMode(mode); - scheduleComposerFocus(); - return; - } - if (isLocalDraftThread) { - setDraftThreadContext(composerDraftTarget, { - envMode: mode, - startFromOrigin: resolveNewDraftStartFromOrigin({ - envMode: mode, - newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, - }), - ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), - }); - } + currentModelSelection: activeThread.modelSelection, + currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null, + nextModelSelection, + }); + if (modelChangeBlockReason) { + toastManager.add({ + type: "warning", + title: modelChangeBlockReason.title, + description: modelChangeBlockReason.description, + }); + if (options?.focusComposer !== false) scheduleComposerFocus(); + return; + } + setComposerDraftModelSelection( + scopeThreadRef(activeThread.environmentId, activeThread.id), + nextModelSelection, + // A complete snapshot: an absent options field means "start from the + // model default", not "keep the previous model's options". + { explicit: true, replaceOptions: true }, + ); + setStickyComposerModelSelection(nextModelSelection); + if (options?.focusComposer !== false) scheduleComposerFocus(); + }; + const onProviderModelSelectStable = useStableCallback(onProviderModelSelect); + const onEnvModeChange = (mode: DraftThreadEnvMode) => { + if (multipleModelSelections !== null) return; + if (canOverrideServerThreadEnvMode) { + setPendingServerThreadEnvMode(mode); scheduleComposerFocus(); - }, - [ - canOverrideServerThreadEnvMode, - composerDraftTarget, - draftThread?.worktreePath, - isLocalDraftThread, - multipleModelSelections, - activeProjectSettings.settings.newWorktreesStartFromOrigin, - setPendingServerThreadEnvMode, - scheduleComposerFocus, - setDraftThreadContext, - ], - ); + return; + } + if (isLocalDraftThread) { + setDraftThreadContext(composerDraftTarget, { + envMode: mode, + startFromOrigin: resolveNewDraftStartFromOrigin({ + envMode: mode, + newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin, + }), + ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), + }); + } + scheduleComposerFocus(); + }; + const onEnvModeChangeStable = useStableCallback(onEnvModeChange); // "Work locally" on the setup card: cancel the bootstrap and remember the // draft. The cancelled dispatch deletes the half-made thread and puts the @@ -10950,7 +10650,7 @@ export default function ChatView(props: ChatViewProps) { // local mode and resends. The draft is a server thread for the whole // setup (the bootstrap created it), so this keys off the route, not // `isLocalDraftThread`. - const onWorktreeSetupWorkLocally = useCallback(() => { + const onWorktreeSetupWorkLocally = () => { if (!worktreeSetup || worktreeSetup.phase !== "running" || !draftId) { return; } @@ -10963,19 +10663,20 @@ export default function ChatView(props: ChatViewProps) { if (result._tag !== "Success" || !result.value.cancelled) return; setWorkLocallyResendDraftId(draftId); })(); - }, [cancelWorktreeSetup, draftId, setupTarget.environmentId, worktreeSetup]); + }; + const onWorktreeSetupWorkLocallyStable = useStableCallback(onWorktreeSetupWorkLocally); const onSendRef = useRef(onSend); onSendRef.current = onSend; const onResumeRef = useRef(onResume); onResumeRef.current = onResume; const onCompactContextRef = useRef(onCompactContext); onCompactContextRef.current = onCompactContext; - const onComposerSend = useCallback( - (...args: Parameters) => onSendRef.current(...args), - [], - ); - const onComposerResume = useCallback(() => onResumeRef.current(), []); - const onComposerCompactContext = useCallback(() => onCompactContextRef.current(), []); + const onComposerSend = (...args: Parameters) => onSendRef.current(...args); + const onComposerSendStable = useStableCallback(onComposerSend); + const onComposerResume = () => onResumeRef.current(); + const onComposerResumeStable = useStableCallback(onComposerResume); + const onComposerCompactContext = () => onCompactContextRef.current(); + const onComposerCompactContextStable = useStableCallback(onComposerCompactContext); const queuedRunsControl = useMemo( () => isServerThread && activeThread ? ( @@ -10991,14 +10692,14 @@ export default function ChatView(props: ChatViewProps) { threadId={activeThread.id} optimisticMessages={optimisticUserMessages} editingRunId={editingQueuedRun?.runId ?? null} - onEditQueuedRun={beginEditingQueuedRun} - onCancelEdit={cancelEditingQueuedRun} + onEditQueuedRun={beginEditingQueuedRunStable} + onCancelEdit={cancelEditingQueuedRunStable} /> ) : null, [ activeThread, - beginEditingQueuedRun, - cancelEditingQueuedRun, + beginEditingQueuedRunStable, + cancelEditingQueuedRunStable, editingQueuedRun?.runId, isServerThread, keybindings, @@ -11063,35 +10764,35 @@ export default function ChatView(props: ChatViewProps) { } }; - const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { + const onExpandTimelineImage = (preview: ExpandedImagePreview) => { setExpandedImage(preview); - }, []); - const onOpenTurnDiff = useCallback( - (runId: RunId, filePath?: string) => { - if (!isServerThread || !activeThreadRef) return; - const diffOpen = - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === - "diff"; - explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; - useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath); - useRightPanelStore.getState().open(activeThreadRef, "diff"); - onDiffPanelOpen?.(); - }, - [activeThreadRef, isServerThread, onDiffPanelOpen], - ); - const onTimelineRollbackCheckpoint = useCallback( - (input: { readonly checkpointId: string; readonly scopeId: string }) => { - if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input); - }, - [onRollbackCheckpoint, paintOnlyDisplayedTimeline], - ); + }; + const onExpandTimelineImageStable = useStableCallback(onExpandTimelineImage); + const onOpenTurnDiff = (runId: RunId, filePath?: string) => { + if (!isServerThread || !activeThreadRef) return; + const diffOpen = + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff"; + explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; + useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath); + useRightPanelStore.getState().open(activeThreadRef, "diff"); + onDiffPanelOpen?.(); + }; + const onOpenTurnDiffStable = useStableCallback(onOpenTurnDiff); + const onTimelineRollbackCheckpoint = (input: { + readonly checkpointId: string; + readonly scopeId: string; + }) => { + if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input); + }; + const onTimelineRollbackCheckpointStable = useStableCallback(onTimelineRollbackCheckpoint); // The revert handler is read from a ref at call-time so the callback // reference is fully stable and never busts TimelineRowCtx identity. - const onRevertToTurnCountRef = useRef(onRevertToTurnCount); - onRevertToTurnCountRef.current = onRevertToTurnCount; - const onRevertTimelineTurn = useCallback((targetTurnCount: number, messageId: MessageId) => { + const onRevertToTurnCountRef = useRef(onRevertToTurnCountStable); + onRevertToTurnCountRef.current = onRevertToTurnCountStable; + const onRevertTimelineTurn = (targetTurnCount: number, messageId: MessageId) => { void onRevertToTurnCountRef.current(targetTurnCount, messageId); - }, []); + }; + const onRevertTimelineTurnStable = useStableCallback(onRevertTimelineTurn); const pendingSidebarFileDrops = useSidebarPendingFileDropStore((state) => state.pending); const consumePendingFileDrop = useSidebarPendingFileDropStore( @@ -11153,12 +10854,12 @@ export default function ChatView(props: ChatViewProps) { launchContext={activeTerminalLaunchContext ?? null} focusRequestId={terminalFocusRequestId} keybindings={keybindings} - onAddTerminalContext={addTerminalContextToDraft} - onSplitTerminal={splitPanelTerminal} - onSplitTerminalVertical={splitPanelTerminalVertical} - onNewTerminal={addTerminalSurface} - onActiveTerminalChange={activatePanelTerminal} - onCloseTerminal={closePanelTerminal} + onAddTerminalContext={addTerminalContextToDraftStable} + onSplitTerminal={splitPanelTerminalStable} + onSplitTerminalVertical={splitPanelTerminalVerticalStable} + onNewTerminal={addTerminalSurfaceStable} + onActiveTerminalChange={activatePanelTerminalStable} + onCloseTerminal={closePanelTerminalStable} splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined} newShortcutLabel={newTerminalShortcutLabel ?? undefined} @@ -11187,7 +10888,7 @@ export default function ChatView(props: ChatViewProps) { // reader's feet. A link the agent wrote can open any other one here, and that one has to be // checkable out like it is anywhere else. 1 - ? addPullRequestsSurface + ? addPullRequestsSurfaceStable : undefined } /> @@ -11278,8 +10979,8 @@ export default function ChatView(props: ChatViewProps) { ? renderedRightPanelSurface.revealRequestId : 0 } - onOpenFile={openFileSurface} - onPendingChange={handleFilePendingChange} + onOpenFile={openFileSurfaceStable} + onPendingChange={handleFilePendingChangeStable} selectedFilePending={ renderedRightPanelSurface.kind === "file" && pendingFileSurfaceIds.has(renderedRightPanelSurface.id) @@ -11315,10 +11016,10 @@ export default function ChatView(props: ChatViewProps) { !envLocked && canAutoBalanceEnvironments && loadBalancingSettings.loadBalancingEnabled - ? onAutoEnvironment + ? onAutoEnvironmentStable : undefined, - onEnvironmentChange, - onEnvModeChange, + onEnvironmentChange: onEnvironmentChangeStable, + onEnvModeChange: onEnvModeChangeStable, envMode, ...(canOverrideServerThreadEnvMode ? { @@ -11329,14 +11030,14 @@ export default function ChatView(props: ChatViewProps) { startFromOrigin, onStartFromOriginChange, ...(canCheckoutPullRequestIntoThread - ? { onCheckoutPullRequestRequest: openPullRequestDialog } + ? { onCheckoutPullRequestRequest: openPullRequestDialogStable } : {}), - onComposerFocusRequest: scheduleComposerFocus, - ...(isServerThread && isGitRepo ? { onOpenChanges: openChangesFromThreadPanel } : {}), - onRunProjectScript: runProjectScript, - onAddProjectScript: saveProjectScript, - onUpdateProjectScript: updateProjectScript, - onDeleteProjectScript: deleteProjectScript, + onComposerFocusRequest: scheduleComposerFocusStable, + ...(isServerThread && isGitRepo ? { onOpenChanges: openChangesFromThreadPanelStable } : {}), + onRunProjectScript: runProjectScriptStable, + onAddProjectScript: saveProjectScriptStable, + onUpdateProjectScript: updateProjectScriptStable, + onDeleteProjectScript: deleteProjectScriptStable, }; const panelToggleControlProps = { terminalAvailable: activeProject !== null, @@ -11349,9 +11050,9 @@ export default function ChatView(props: ChatViewProps) { rightPanelAvailable: activeProject !== null, rightPanelOpen, rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"), - onToggleTerminal: toggleTerminalVisibility, - onToggleThreadPanel: toggleThreadPanel, - onToggleRightPanel: toggleRightPanel, + onToggleTerminal: toggleTerminalVisibilityStable, + onToggleThreadPanel: toggleThreadPanelStable, + onToggleRightPanel: toggleRightPanelStable, } satisfies PanelLayoutControlsProps; const panelToggleControls = ( ) : null} @@ -11481,11 +11182,11 @@ export default function ChatView(props: ChatViewProps) { activeThreadTitle={activeThread.title} activeProject={activeProject ?? null} parentThreadLink={parentThreadLink} - onOpenThread={onOpenRelatedThread} + onOpenThread={onOpenRelatedThreadStable} rightPanelOpen={inlineRightPanelOwnsTitleBar} - onNewThreadInProject={handleNewThreadInActiveProject} + onNewThreadInProject={handleNewThreadInActiveProjectStable} {...(activeDraftLogicalProjectKey - ? { onOpenProjectSettings: handleOpenDraftProjectSettings } + ? { onOpenProjectSettings: handleOpenDraftProjectSettingsStable } : {})} /> @@ -11512,7 +11213,7 @@ export default function ChatView(props: ChatViewProps) { onDragLeave={workspaceFileDropHandlers.onDragLeave} onDrop={workspaceFileDropHandlers.onDrop} > - + {isWorkspaceFileDragActive ? (
setDismissedProviderStatusBannerKey(providerStatusBannerKey)} - onOpenProviderSetup={openProviderSetup} + onOpenProviderSetup={openProviderSetupStable} /> {} : onForkFromRun} - onRollbackCheckpoint={onTimelineRollbackCheckpoint} + onForkFromRun={paintOnlyDisplayedTimeline ? async () => {} : onForkFromRunStable} + onRollbackCheckpoint={onTimelineRollbackCheckpointStable} supportsConversationRollback={ !paintOnlyDisplayedTimeline && supportsConversationRollback } onRevertToTurnCount={ - paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn + paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurnStable } {...(!paintOnlyDisplayedTimeline - ? { onUseArtifactTemplate: useArtifactTemplate, onSendAppMessage: sendAppMessage } + ? { + onUseArtifactTemplate: useArtifactTemplateStable, + onSendAppMessage: sendAppMessageStable, + } : {})} isRevertingCheckpoint={isRevertingCheckpoint} - onImageExpand={onExpandTimelineImage} - onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment} + onImageExpand={onExpandTimelineImageStable} + onFileOpen={ + paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachmentStable + } onFileDownload={ - paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachment + paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachmentStable } markdownCwd={ paintOnlyDisplayedTimeline @@ -11651,14 +11364,14 @@ export default function ChatView(props: ChatViewProps) { } skills={timelineSkills} anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId} - onAnchorReady={onTimelineAnchorReady} - onAnchorSizeChanged={onTimelineAnchorSizeChanged} + onAnchorReady={onTimelineAnchorReadyStable} + onAnchorSizeChanged={onTimelineAnchorSizeChangedStable} contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} - onIsAtEndChange={onIsAtEndChange} + onIsAtEndChange={onIsAtEndChangeStable} onContentOverflowChange={setTimelineOverflows} - onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} - onManualNavigation={cancelTimelineLiveFollowForUserNavigation} + onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEndStable} + onManualNavigation={cancelTimelineLiveFollowForUserNavigationStable} cancelPositionRestoreRef={cancelPositionRestoreRef} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} @@ -11774,7 +11487,7 @@ export default function ChatView(props: ChatViewProps) { serverConfig?.environment.capabilities.requiredWorktreeBootstrap === true } - onMultipleModelSelectionsChange={setMultipleModelSelections} + onMultipleModelSelectionsChange={setMultipleModelSelectionsStable} composerRef={composerRef} composerDraftTarget={composerDraftTarget} environmentId={environmentId} @@ -11830,14 +11543,14 @@ export default function ChatView(props: ChatViewProps) { bannerItems={composerBannerItems} resumeCompactionTokens={resumeCompactionTokens} keepFullHistory={keepFullHistory} - onToggleKeepFullHistory={toggleKeepFullHistory} + onToggleKeepFullHistory={toggleKeepFullHistoryStable} // With attachments or contexts aboard the pick just inserts the // text, so it sends as a prompt like the typed path would. onUsageLimitsCommand={ usageLimitsOffered && usageLimitsKey !== null && !composerHasNonPromptContent - ? openUsageLimits + ? openUsageLimitsStable : undefined } environmentUnavailable={activeEnvironmentUnavailableState} @@ -11889,48 +11602,50 @@ export default function ChatView(props: ChatViewProps) { (isGitRepo || showComposerEnvironmentIndicator) } onRestingControlsVisibilityChange={setRestingComposerControlsVisible} - getTimelineScrollableNode={getTimelineScrollableNode} - isTimelineAtLogicalEnd={isTimelineAtLogicalEnd} + getTimelineScrollableNode={getTimelineScrollableNodeStable} + isTimelineAtLogicalEnd={isTimelineAtLogicalEndStable} timelineOverflows={timelineOverflows} - onComposerOverlayHeightChange={publishComposerOverlayHeight} - onRestingChange={onComposerRestingChange} + onComposerOverlayHeightChange={publishComposerOverlayHeightStable} + onRestingChange={onComposerRestingChangeStable} promptRef={promptRef} composerImagesRef={composerImagesRef} composerFilesRef={composerFilesRef} composerTerminalContextsRef={composerTerminalContextsRef} - onPageScrollKeyDown={onComposerPageScrollKeyDown} - onPageScrollKeyUp={onComposerPageScrollKeyUp} - onPageScrollRelease={onComposerPageScrollRelease} - onCompactContext={onComposerCompactContext} - onSend={onComposerSend} - onResume={onComposerResume} - onInterrupt={onInterrupt} - onImplementPlanInNewThread={onImplementPlanInNewThread} - onRespondToApproval={onRespondToApproval} + onPageScrollKeyDown={onComposerPageScrollKeyDownStable} + onPageScrollKeyUp={onComposerPageScrollKeyUpStable} + onPageScrollRelease={onComposerPageScrollReleaseStable} + onCompactContext={onComposerCompactContextStable} + onSend={onComposerSendStable} + onResume={onComposerResumeStable} + onInterrupt={onInterruptStable} + onImplementPlanInNewThread={onImplementPlanInNewThreadStable} + onRespondToApproval={onRespondToApprovalStable} onSelectActivePendingUserInputOption={ - onSelectActivePendingUserInputOption + onSelectActivePendingUserInputOptionStable } - onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} - onDismissActivePendingUserInput={onDismissUserInput} + onAdvanceActivePendingUserInput={ + onAdvanceActivePendingUserInputStable + } + onDismissActivePendingUserInput={onDismissUserInputStable} onPreviousActivePendingUserInputQuestion={ - onPreviousActivePendingUserInputQuestion + onPreviousActivePendingUserInputQuestionStable } onChangeActivePendingUserInputCustomAnswer={ onChangeActivePendingUserInputCustomAnswer } - onProviderModelSelect={onProviderModelSelect} - onOpenProviderSetup={openProviderSetup} + onProviderModelSelect={onProviderModelSelectStable} + onOpenProviderSetup={openProviderSetupStable} getModelDisabledReason={getModelDisabledReason} - toggleInteractionMode={toggleInteractionMode} - handleRuntimeModeChange={handleRuntimeModeChange} - handleInteractionModeChange={handleInteractionModeChange} - focusComposer={focusComposer} - scheduleComposerFocus={scheduleComposerFocus} + toggleInteractionMode={toggleInteractionModeStable} + handleRuntimeModeChange={handleRuntimeModeChangeStable} + handleInteractionModeChange={handleInteractionModeChangeStable} + focusComposer={focusComposerStable} + scheduleComposerFocus={scheduleComposerFocusStable} setThreadError={setThreadError} - onExpandImage={onExpandTimelineImage} - onFileOpen={openFileAttachment} + onExpandImage={onExpandTimelineImageStable} + onFileOpen={openFileAttachmentStable} editingQueuedAttachments={composerEditingQueuedAttachments} - onRemoveEditingQueuedAttachment={removeEditingQueuedAttachment} + onRemoveEditingQueuedAttachment={removeEditingQueuedAttachmentStable} /> )}
@@ -11966,7 +11681,7 @@ export default function ChatView(props: ChatViewProps) { threadId={activeThread.id} showGitControls={isGitRepo} {...(routeKind === "draft" && draftId ? { draftId } : {})} - onEnvModeChange={onEnvModeChange} + onEnvModeChange={onEnvModeChangeStable} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} envMode={envMode} @@ -11978,18 +11693,20 @@ export default function ChatView(props: ChatViewProps) { } : {})} envLocked={envLocked} - onComposerFocusRequest={scheduleComposerFocus} + onComposerFocusRequest={scheduleComposerFocusStable} {...(canCheckoutPullRequestIntoThread - ? { onCheckoutPullRequestRequest: openPullRequestDialog } + ? { onCheckoutPullRequestRequest: openPullRequestDialogStable } + : {})} + {...(hasMultipleEnvironments + ? { onEnvironmentChange: onEnvironmentChangeStable } : {})} - {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} autoEnvironmentLabel={autoEnvironmentLabel} onAutoEnvironment={ draftId && !envLocked && canAutoBalanceEnvironments && loadBalancingSettings.loadBalancingEnabled - ? onAutoEnvironment + ? onAutoEnvironmentStable : undefined } availableEnvironments={logicalProjectEnvironments} @@ -12070,7 +11787,7 @@ export default function ChatView(props: ChatViewProps) { closePullRequestDialog(); } }} - onPrepared={handlePreparedPullRequestThread} + onPrepared={handlePreparedPullRequestThreadStable} /> ) : null} @@ -12103,7 +11820,7 @@ export default function ChatView(props: ChatViewProps) { mode="inline" open={rightPanelOpen} keybindings={keybindings} - getShortcutContext={getShortcutContext} + getShortcutContext={getShortcutContextStable} maximized={rightPanelMaximized} inlineSize={previewPanelInlineSize} surfaces={renderedRightPanelSurfaces} @@ -12114,25 +11831,25 @@ export default function ChatView(props: ChatViewProps) { desktopByTabId={activePreviewState.desktopByTabId} previewRuntimeTabId={resolvePreviewRuntimeTabId} terminalLabelsById={activeTerminalLabelsById} - onActivate={activateRightPanelSurface} - onCloseSurface={closeRightPanelSurface} + onActivate={activateRightPanelSurfaceStable} + onCloseSurface={closeRightPanelSurfaceStable} onRenameDevice={(surfaceId, title) => { if (activeThreadRef) useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); }} - onCloseOtherSurfaces={closeOtherRightPanelSurfaces} - onCloseSurfacesToRight={closeRightPanelSurfacesToRight} - onCloseAllSurfaces={closeAllRightPanelSurfaces} - onMoveSurface={moveRightPanelSurface} - onCopyFilePath={copyRightPanelFilePath} + onCloseOtherSurfaces={closeOtherRightPanelSurfacesStable} + onCloseSurfacesToRight={closeRightPanelSurfacesToRightStable} + onCloseAllSurfaces={closeAllRightPanelSurfacesStable} + onMoveSurface={moveRightPanelSurfaceStable} + onCopyFilePath={copyRightPanelFilePathStable} onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurface} - onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} - onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} + onAddBrowserInProfile={createBrowserSurfaceStable} + onAddTerminal={addTerminalSurfaceStable} + onAddDiff={addDiffSurfaceStable} + onAddFiles={addFilesSurfaceStable} + onAddPullRequest={addPullRequestSurfaceStable} + onAddPullRequests={addPullRequestsSurfaceStable} + onAddDevice={addDeviceSurfaceStable} browserAvailable={canOperatePreview && browserAvailable} terminalAvailable={activeProject !== null && canOperateTerminal} diffAvailable={isServerThread && isGitRepo} @@ -12148,13 +11865,13 @@ export default function ChatView(props: ChatViewProps) { { if (activeThreadRef) useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); }} - onCloseOtherSurfaces={closeOtherRightPanelSurfaces} - onCloseSurfacesToRight={closeRightPanelSurfacesToRight} - onCloseAllSurfaces={closeAllRightPanelSurfaces} - onMoveSurface={moveRightPanelSurface} - onCopyFilePath={copyRightPanelFilePath} + onCloseOtherSurfaces={closeOtherRightPanelSurfacesStable} + onCloseSurfacesToRight={closeRightPanelSurfacesToRightStable} + onCloseAllSurfaces={closeAllRightPanelSurfacesStable} + onMoveSurface={moveRightPanelSurfaceStable} + onCopyFilePath={copyRightPanelFilePathStable} onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurface} - onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} - onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} + onAddBrowserInProfile={createBrowserSurfaceStable} + onAddTerminal={addTerminalSurfaceStable} + onAddDiff={addDiffSurfaceStable} + onAddFiles={addFilesSurfaceStable} + onAddPullRequest={addPullRequestSurfaceStable} + onAddPullRequests={addPullRequestsSurfaceStable} + onAddDevice={addDeviceSurfaceStable} browserAvailable={canOperatePreview && browserAvailable} terminalAvailable={activeProject !== null && canOperateTerminal} diffAvailable={isServerThread && isGitRepo} @@ -12248,7 +11965,7 @@ export default function ChatView(props: ChatViewProps) { )} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 05a5e6702092..5f775123d49e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1154,6 +1154,7 @@ import { import { searchProviderSkills } from "../../providerSkillSearch"; import { useDelayedStatus } from "../../hooks/useDelayedStatus"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import { useStableCallback } from "../../hooks/useStableCallback"; import { usePanelAnimationSettings } from "../../panelAnimations"; import { useAtomCommand } from "../../state/use-atom-command"; import { readEnvironmentScope } from "../../state/session"; @@ -3005,27 +3006,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Provider traits UI // ------------------------------------------------------------------ - const setPromptFromTraits = useCallback( - (nextPrompt: string) => { - if (nextPrompt === promptRef.current) { - scheduleComposerFocus(); - return; - } - promptRef.current = nextPrompt; - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); - setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); + const setPromptFromTraits = (nextPrompt: string) => { + if (nextPrompt === promptRef.current) { scheduleComposerFocus(); - }, - [ - composerDraftTarget, - promptRef, - scheduleComposerFocus, - setComposerDraftPrompt, - setComposerTrigger, - ], - ); + return; + } + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); + setComposerCursor(nextCursor); + setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); + scheduleComposerFocus(); + }; + const setPromptFromTraitsStable = useStableCallback(setPromptFromTraits); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ provider: selectedProvider, @@ -3037,7 +3030,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelOptions: composerModelOptions?.[selectedInstanceId], reportedModelSelection, prompt, - onPromptChange: setPromptFromTraits, + onPromptChange: setPromptFromTraitsStable, planModeEnabled: settings.planModeEnabled, }); const providerTraitsPickerInput = { @@ -3050,7 +3043,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelOptions: composerModelOptions?.[selectedInstanceId], reportedModelSelection, prompt, - onPromptChange: setPromptFromTraits, + onPromptChange: setPromptFromTraitsStable, planModeEnabled: settings.planModeEnabled, isComposerOwned: true, } satisfies Parameters[0]; @@ -3111,66 +3104,39 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Prompt helpers // ------------------------------------------------------------------ - const setPrompt = useCallback( - (nextPrompt: string) => { - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - }, - [composerDraftTarget, setComposerDraftPrompt], - ); + const setPrompt = (nextPrompt: string) => { + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + }; - const addComposerImage = useCallback( - (image: ComposerImageAttachment) => addComposerDraftImages(attachmentDraftTarget, [image]), - [attachmentDraftTarget, addComposerDraftImages], - ); + const addComposerImage = (image: ComposerImageAttachment) => + addComposerDraftImages(attachmentDraftTarget, [image]); - const addComposerImagesToDraft = useCallback( - (images: ComposerImageAttachment[]) => addComposerDraftImages(attachmentDraftTarget, images), - [attachmentDraftTarget, addComposerDraftImages], - ); + const addComposerImagesToDraft = (images: ComposerImageAttachment[]) => + addComposerDraftImages(attachmentDraftTarget, images); - const addComposerFilesToDraft = useCallback( - (files: ComposerFileAttachment[]) => - addComposerDraftFiles(attachmentDraftTarget, files, { - appendReference: questionAttachmentTarget === null, - }), - [addComposerDraftFiles, attachmentDraftTarget, questionAttachmentTarget], - ); + const addComposerFilesToDraft = (files: ComposerFileAttachment[]) => + addComposerDraftFiles(attachmentDraftTarget, files, { + appendReference: questionAttachmentTarget === null, + }); - const removeComposerImageFromDraft = useCallback( - (imageId: string) => { - if (questionAttachmentTarget && activePendingIsResponding) return; - releaseAttachmentUpload(imageId); - removeComposerDraftImage(attachmentDraftTarget, imageId); - }, - [ - attachmentDraftTarget, - questionAttachmentTarget, - activePendingIsResponding, - removeComposerDraftImage, - ], - ); + const removeComposerImageFromDraft = (imageId: string) => { + if (questionAttachmentTarget && activePendingIsResponding) return; + releaseAttachmentUpload(imageId); + removeComposerDraftImage(attachmentDraftTarget, imageId); + }; - const removeComposerFileFromDraft = useCallback( - (fileId: string) => { - if (questionAttachmentTarget && activePendingIsResponding) return; - // Release by the draft attachment, not the bare queue key: a hydrated - // file's upload lives server-side under its persisted attachment id. - const file = composerFilesRef.current.find((candidate) => candidate.id === fileId); - if (file) { - releaseDraftAttachment(file); - } else { - releaseAttachmentUpload(fileId); - } - removeComposerDraftFile(attachmentDraftTarget, fileId); - }, - [ - attachmentDraftTarget, - questionAttachmentTarget, - activePendingIsResponding, - composerFilesRef, - removeComposerDraftFile, - ], - ); + const removeComposerFileFromDraft = (fileId: string) => { + if (questionAttachmentTarget && activePendingIsResponding) return; + // Release by the draft attachment, not the bare queue key: a hydrated + // file's upload lives server-side under its persisted attachment id. + const file = composerFilesRef.current.find((candidate) => candidate.id === fileId); + if (file) { + releaseDraftAttachment(file); + } else { + releaseAttachmentUpload(fileId); + } + removeComposerDraftFile(attachmentDraftTarget, fileId); + }; const addComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.addTerminalContexts, @@ -3181,72 +3147,60 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const addComposerDraftThreadContexts = useComposerDraftStore((store) => store.addThreadContexts); const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); - const buildContextClipboardFragment = useCallback( - (contextIds: ReadonlyArray): string | null => { - const wanted = new Set(contextIds); - // An annotation's screenshot is referenced by the annotation record, not by the copied - // text. Pull it in so the round-trip keeps the image the annotation points at. - for (const annotation of composerPreviewAnnotations) { - if ( - wanted.has(previewAnnotationContextId(annotation.id)) && - composerImages.some((image) => image.id === annotation.id) - ) { - wanted.add(toKindScopedComposerContextId("image", annotation.id)); - } + const buildContextClipboardFragment = (contextIds: ReadonlyArray): string | null => { + const wanted = new Set(contextIds); + // An annotation's screenshot is referenced by the annotation record, not by the copied + // text. Pull it in so the round-trip keeps the image the annotation points at. + for (const annotation of composerPreviewAnnotations) { + if ( + wanted.has(previewAnnotationContextId(annotation.id)) && + composerImages.some((image) => image.id === annotation.id) + ) { + wanted.add(toKindScopedComposerContextId("image", annotation.id)); } - const records: ComposerContextRecord[] = [ - ...composerTerminalContexts - .filter((c) => wanted.has(terminalContextReference(c).contextId)) - .map(terminalContextRecord), - ...composerThreadContexts.filter((record) => wanted.has(record.contextId)), - ...composerReviewComments - .filter((c) => wanted.has(reviewCommentContextId(c.id))) - .map(reviewCommentContextRecord), - ...composerPreviewAnnotations - .filter((a) => wanted.has(previewAnnotationContextId(a.id))) - .map((annotation) => - previewAnnotationContextRecord(annotation, { - screenshotContextId: composerImages.some((image) => image.id === annotation.id) - ? annotation.id - : undefined, - }), - ), - ...[...composerImages, ...composerFiles] - .filter((attachment) => - wanted.has(toKindScopedComposerContextId(attachment.type, attachment.id)), - ) - .flatMap((attachment) => { - const record = uploadedAttachmentContextRecord( - attachment, - uploadsByImageId[attachment.id], - ); - return record ? [record] : []; + } + const records: ComposerContextRecord[] = [ + ...composerTerminalContexts + .filter((c) => wanted.has(terminalContextReference(c).contextId)) + .map(terminalContextRecord), + ...composerThreadContexts.filter((record) => wanted.has(record.contextId)), + ...composerReviewComments + .filter((c) => wanted.has(reviewCommentContextId(c.id))) + .map(reviewCommentContextRecord), + ...composerPreviewAnnotations + .filter((a) => wanted.has(previewAnnotationContextId(a.id))) + .map((annotation) => + previewAnnotationContextRecord(annotation, { + screenshotContextId: composerImages.some((image) => image.id === annotation.id) + ? annotation.id + : undefined, }), - ]; - if (records.length === 0) return null; - return encodeComposerContextFragment({ - version: 1, - source: { environmentId, ...(activeThread ? { threadId: activeThread.id } : {}) }, - records, - }); - }, - [ - activeThread, - composerFiles, - composerImages, - composerPreviewAnnotations, - composerReviewComments, - composerTerminalContexts, - composerThreadContexts, - environmentId, - uploadsByImageId, - ], - ); + ), + ...[...composerImages, ...composerFiles] + .filter((attachment) => + wanted.has(toKindScopedComposerContextId(attachment.type, attachment.id)), + ) + .flatMap((attachment) => { + const record = uploadedAttachmentContextRecord( + attachment, + uploadsByImageId[attachment.id], + ); + return record ? [record] : []; + }), + ]; + if (records.length === 0) return null; + return encodeComposerContextFragment({ + version: 1, + source: { environmentId, ...(activeThread ? { threadId: activeThread.id } : {}) }, + records, + }); + }; + const buildContextClipboardFragmentStable = useStableCallback(buildContextClipboardFragment); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false }); const loadThreadTranscript = useAtomCommand(orchestrationEnvironment.threadTranscript, { reportFailure: false, }); - const countReservedAttachments = useCallback(() => { + const countReservedAttachments = () => { const questionRequest = pendingUserInputs[0]; const otherQuestionKeys = questionAttachmentTarget && questionRequest && activeThreadId @@ -3269,272 +3223,238 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (pendingThreadImportsRef.current.get(attachmentTargetKey) ?? 0) + countQuestionAttachments(otherQuestionKeys) ); - }, [ - activeThreadId, - attachmentDraftTarget, - attachmentTargetKey, - environmentId, - getComposerDraft, - pendingUserInputs, - questionAttachmentTarget, - ]); + }; + const countReservedAttachmentsStable = useStableCallback(countReservedAttachments); /** * Bytes for a pasted image or file come back through the source environment's asset URL * (the client is the only party that can reach both) and re-enter this draft as a normal * attachment under a fresh id. The pasted chip is rewritten to that id and reads as * unresolved until the bytes land; a failed transfer says so and leaves the chip to remove. */ - const runAttachmentImport = useCallback( - async ( - record: Extract, - localId: string, - sourceEnvironmentId: EnvironmentId, - importTargetKey: string, - ) => { - const fail = (reason: string) => { - toastManager.add({ - type: "error", - title: `Couldn't bring ${record.name} into this message`, - description: `${reason} Remove the chip or attach the file again.`, - }); - }; - const sourceConnection = readPreparedConnection(sourceEnvironmentId); - if (!sourceConnection) { - fail("The environment it came from is not connected."); - return; - } - const result = await createAssetUrl({ - environmentId: sourceEnvironmentId, - input: { resource: { _tag: "attachment", attachmentId: record.attachmentId } }, + const runAttachmentImport = async ( + record: Extract, + localId: string, + sourceEnvironmentId: EnvironmentId, + importTargetKey: string, + ) => { + const fail = (reason: string) => { + toastManager.add({ + type: "error", + title: `Couldn't bring ${record.name} into this message`, + description: `${reason} Remove the chip or attach the file again.`, }); - const url = - result._tag === "Success" - ? resolveAssetUrl(sourceConnection.httpBaseUrl, result.value.relativeUrl) - : null; - if (!url) { - fail("The original attachment is no longer available."); - return; - } - let blob: Blob; - try { - const response = await fetch(url, { signal: AbortSignal.timeout(60_000) }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - blob = await response.blob(); - } catch { - fail("Downloading it from the source failed."); - return; - } - const file = new File([blob], record.name, { type: record.mimeType || blob.type }); - // The draft these bytes belong to may have been sent or switched away from while they - // downloaded. Dropping them here keeps them out of whatever draft is open now. - if (attachmentTargetKeyRef.current !== importTargetKey) return; - const replacesFileMarker = - record.kind === "file" && - composerFilesRef.current.some( - (candidate) => - composerFileNeedsReattach(candidate) && - composerFileMatchesReattachMarker(candidate, { - name: record.name, - mimeType: file.type, - sizeBytes: file.size, - }), - ); - if ( - !replacesFileMarker && - (pendingThreadImportsRef.current.get(importTargetKey) ?? 0) > 0 && - countReservedAttachments() >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS - ) { - fail("The attachment limit has been reached."); - return; - } - if (record.kind === "image") { - const accepted = addComposerImage({ - type: "image", + }; + const sourceConnection = readPreparedConnection(sourceEnvironmentId); + if (!sourceConnection) { + fail("The environment it came from is not connected."); + return; + } + const result = await createAssetUrl({ + environmentId: sourceEnvironmentId, + input: { resource: { _tag: "attachment", attachmentId: record.attachmentId } }, + }); + const url = + result._tag === "Success" + ? resolveAssetUrl(sourceConnection.httpBaseUrl, result.value.relativeUrl) + : null; + if (!url) { + fail("The original attachment is no longer available."); + return; + } + let blob: Blob; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + blob = await response.blob(); + } catch { + fail("Downloading it from the source failed."); + return; + } + const file = new File([blob], record.name, { type: record.mimeType || blob.type }); + // The draft these bytes belong to may have been sent or switched away from while they + // downloaded. Dropping them here keeps them out of whatever draft is open now. + if (attachmentTargetKeyRef.current !== importTargetKey) return; + const replacesFileMarker = + record.kind === "file" && + composerFilesRef.current.some( + (candidate) => + composerFileNeedsReattach(candidate) && + composerFileMatchesReattachMarker(candidate, { + name: record.name, + mimeType: file.type, + sizeBytes: file.size, + }), + ); + if ( + !replacesFileMarker && + (pendingThreadImportsRef.current.get(importTargetKey) ?? 0) > 0 && + countReservedAttachments() >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS + ) { + fail("The attachment limit has been reached."); + return; + } + if (record.kind === "image") { + const accepted = addComposerImage({ + type: "image", + id: localId, + name: record.name, + mimeType: file.type, + sizeBytes: file.size, + previewUrl: URL.createObjectURL(file), + file, + }); + if (!accepted.includes(localId)) + fail("The draft rejected this attachment (duplicate or attachment limit reached)."); + } else { + const accepted = addComposerFilesToDraft([ + { + type: "file", id: localId, name: record.name, mimeType: file.type, sizeBytes: file.size, - previewUrl: URL.createObjectURL(file), file, - }); - if (!accepted.includes(localId)) - fail("The draft rejected this attachment (duplicate or attachment limit reached)."); - } else { - const accepted = addComposerFilesToDraft([ - { - type: "file", - id: localId, - name: record.name, - mimeType: file.type, - sizeBytes: file.size, - file, - }, - ]); - if (!accepted.includes(localId)) - fail("The draft rejected this attachment (duplicate or attachment limit reached)."); - } - }, - [ - addComposerFilesToDraft, - addComposerImage, - composerFilesRef, - countReservedAttachments, - createAssetUrl, - ], - ); - const importAttachmentRecord = useCallback( - async ( - record: Extract, - localId: string, - sourceEnvironmentId: EnvironmentId, - ) => { - // The chip lands in the draft immediately while these bytes are still downloading. Count - // the transfer against its own draft so a send cannot snapshot a message whose chip has no - // attachment behind it, and so bytes for an abandoned draft never enter the next one. - const importTargetKey = attachmentTargetKey; - pendingDraftWork.begin(importTargetKey); - try { - await runAttachmentImport(record, localId, sourceEnvironmentId, importTargetKey); - } finally { - pendingDraftWork.end(importTargetKey); - } - }, - [attachmentTargetKey, runAttachmentImport], - ); + }, + ]); + if (!accepted.includes(localId)) + fail("The draft rejected this attachment (duplicate or attachment limit reached)."); + } + }; + const importAttachmentRecord = async ( + record: Extract, + localId: string, + sourceEnvironmentId: EnvironmentId, + ) => { + // The chip lands in the draft immediately while these bytes are still downloading. Count + // the transfer against its own draft so a send cannot snapshot a message whose chip has no + // attachment behind it, and so bytes for an abandoned draft never enter the next one. + const importTargetKey = attachmentTargetKey; + pendingDraftWork.begin(importTargetKey); + try { + await runAttachmentImport(record, localId, sourceEnvironmentId, importTargetKey); + } finally { + pendingDraftWork.end(importTargetKey); + } + }; /** * Brings records into this draft (paste, stash restore). Binaries are transferred only * when `sourceEnvironmentId` is given; the stash restores its own images and files. */ - const importContextRecords = useCallback( - ( - records: ReadonlyArray, - sourceEnvironmentId: EnvironmentId | null, - ): ReadonlyMap => { - const rewritten = new Map(); - const dependentAttachmentLocalIds = new Map(); - const skippedDependentAttachmentIds = new Set(); - // Resolve annotations before their dependent screenshot records even if a foreign - // clipboard producer emitted the records in a different order. - const orderedRecords = records.toSorted((left, right) => - left.kind === "preview-annotation" && right.kind !== "preview-annotation" - ? -1 - : right.kind === "preview-annotation" && left.kind !== "preview-annotation" - ? 1 - : 0, - ); - for (const candidate of orderedRecords) { - // Producer ids fold into context ids, so two different excerpts can collide. Only skip - // when the draft already holds the same payload; a colliding but different record is - // re-minted under a fresh id so both survive the paste. - const record = asKnownContextRecord(candidate); - if (!record) continue; - const existing = composerContextImportLookupIds(record).flatMap((contextId) => { - const found = composerContextRecords.get(contextId); - return found ? [found] : []; - })[0]; - const existingRecord = - existing?.kind === "terminal" - ? terminalContextRecord(existing.record) - : existing?.kind === "review-comment" - ? reviewCommentContextRecord(existing.record) - : existing?.kind === "preview-annotation" - ? previewAnnotationContextRecord(existing.record) - : existing?.kind === "thread" - ? existing.record - : existing - ? (uploadedContextRecordFromDraft(existing) ?? undefined) - : undefined; - if (existingRecord && isSameComposerContextPayload(existingRecord, record)) { + const importContextRecords = ( + records: ReadonlyArray, + sourceEnvironmentId: EnvironmentId | null, + ): ReadonlyMap => { + const rewritten = new Map(); + const dependentAttachmentLocalIds = new Map(); + const skippedDependentAttachmentIds = new Set(); + // Resolve annotations before their dependent screenshot records even if a foreign + // clipboard producer emitted the records in a different order. + const orderedRecords = records.toSorted((left, right) => + left.kind === "preview-annotation" && right.kind !== "preview-annotation" + ? -1 + : right.kind === "preview-annotation" && left.kind !== "preview-annotation" + ? 1 + : 0, + ); + for (const candidate of orderedRecords) { + // Producer ids fold into context ids, so two different excerpts can collide. Only skip + // when the draft already holds the same payload; a colliding but different record is + // re-minted under a fresh id so both survive the paste. + const record = asKnownContextRecord(candidate); + if (!record) continue; + const existing = composerContextImportLookupIds(record).flatMap((contextId) => { + const found = composerContextRecords.get(contextId); + return found ? [found] : []; + })[0]; + const existingRecord = + existing?.kind === "terminal" + ? terminalContextRecord(existing.record) + : existing?.kind === "review-comment" + ? reviewCommentContextRecord(existing.record) + : existing?.kind === "preview-annotation" + ? previewAnnotationContextRecord(existing.record) + : existing?.kind === "thread" + ? existing.record + : existing + ? (uploadedContextRecordFromDraft(existing) ?? undefined) + : undefined; + if (existingRecord && isSameComposerContextPayload(existingRecord, record)) { + if (record.kind === "preview-annotation" && record.screenshotContextId) { + skippedDependentAttachmentIds.add(record.screenshotContextId); + } + continue; + } + const conflicts = existing !== undefined; + switch (record.kind) { + case "terminal": { + const threadId = activeThread?.id ?? activeThreadId; + if (!threadId) break; + const imported = terminalContextDraftFromRecord(record, threadId); + const draft = conflicts ? { ...imported, id: randomUUID() } : imported; + addComposerDraftTerminalContexts(composerDraftTarget, [draft], { + appendReference: false, + }); + rewritten.set(record.contextId, terminalContextReference(draft).contextId); + break; + } + case "review-comment": { + const imported = reviewCommentFromRecord(record); + const comment = conflicts ? { ...imported, id: randomUUID() } : imported; + addComposerDraftReviewComment(composerDraftTarget, comment, { + appendReference: false, + }); + rewritten.set(record.contextId, reviewCommentContextId(comment.id)); + break; + } + case "element": + case "preview-annotation": { + const imported = + record.kind === "element" + ? elementContextToPreviewAnnotation(record, randomUUID(), new Date().toISOString()) + : previewAnnotationFromRecord(record); + const annotation = conflicts ? { ...imported, id: randomUUID() } : imported; if (record.kind === "preview-annotation" && record.screenshotContextId) { - skippedDependentAttachmentIds.add(record.screenshotContextId); + dependentAttachmentLocalIds.set(record.screenshotContextId, annotation.id); } - continue; + addComposerDraftPreviewAnnotation(composerDraftTarget, annotation, { + appendReference: false, + }); + rewritten.set(record.contextId, previewAnnotationContextId(annotation.id)); + break; } - const conflicts = existing !== undefined; - switch (record.kind) { - case "terminal": { - const threadId = activeThread?.id ?? activeThreadId; - if (!threadId) break; - const imported = terminalContextDraftFromRecord(record, threadId); - const draft = conflicts ? { ...imported, id: randomUUID() } : imported; - addComposerDraftTerminalContexts(composerDraftTarget, [draft], { - appendReference: false, - }); - rewritten.set(record.contextId, terminalContextReference(draft).contextId); - break; - } - case "review-comment": { - const imported = reviewCommentFromRecord(record); - const comment = conflicts ? { ...imported, id: randomUUID() } : imported; - addComposerDraftReviewComment(composerDraftTarget, comment, { - appendReference: false, - }); - rewritten.set(record.contextId, reviewCommentContextId(comment.id)); - break; - } - case "element": - case "preview-annotation": { - const imported = - record.kind === "element" - ? elementContextToPreviewAnnotation(record, randomUUID(), new Date().toISOString()) - : previewAnnotationFromRecord(record); - const annotation = conflicts ? { ...imported, id: randomUUID() } : imported; - if (record.kind === "preview-annotation" && record.screenshotContextId) { - dependentAttachmentLocalIds.set(record.screenshotContextId, annotation.id); - } - addComposerDraftPreviewAnnotation(composerDraftTarget, annotation, { - appendReference: false, - }); - rewritten.set(record.contextId, previewAnnotationContextId(annotation.id)); - break; - } - case "thread": { - // The agent can only read threads on its own server; a pasted foreign one is dropped. - if (record.environmentId !== environmentId) break; - addComposerDraftThreadContexts(attachmentDraftTarget, [record], { - appendReference: false, - }); + case "thread": { + // The agent can only read threads on its own server; a pasted foreign one is dropped. + if (record.environmentId !== environmentId) break; + addComposerDraftThreadContexts(attachmentDraftTarget, [record], { + appendReference: false, + }); + rewritten.set(record.contextId, record.contextId); + break; + } + case "image": + case "file": { + if (skippedDependentAttachmentIds.has(record.contextId)) break; + if (sourceEnvironmentId === null && !conflicts) { rewritten.set(record.contextId, record.contextId); break; } - case "image": - case "file": { - if (skippedDependentAttachmentIds.has(record.contextId)) break; - if (sourceEnvironmentId === null && !conflicts) { - rewritten.set(record.contextId, record.contextId); - break; - } - if (sourceEnvironmentId === null) break; - const localId = dependentAttachmentLocalIds.get(record.contextId) ?? randomUUID(); - rewritten.set(record.contextId, toKindScopedComposerContextId(record.kind, localId)); - void importAttachmentRecord(record, localId, sourceEnvironmentId); - break; - } - default: - break; + if (sourceEnvironmentId === null) break; + const localId = dependentAttachmentLocalIds.get(record.contextId) ?? randomUUID(); + rewritten.set(record.contextId, toKindScopedComposerContextId(record.kind, localId)); + void importAttachmentRecord(record, localId, sourceEnvironmentId); + break; } + default: + break; } - return rewritten; - }, - [ - activeThread, - activeThreadId, - addComposerDraftPreviewAnnotation, - addComposerDraftReviewComment, - addComposerDraftTerminalContexts, - addComposerDraftThreadContexts, - composerContextRecords, - attachmentDraftTarget, - composerDraftTarget, - environmentId, - importAttachmentRecord, - ], - ); - const importContextFragment = useCallback( - (fragment: ComposerContextClipboardFragment): ReadonlyMap => - importContextRecords(fragment.records, fragment.source.environmentId), - [importContextRecords], - ); + } + return rewritten; + }; + const importContextFragment = ( + fragment: ComposerContextClipboardFragment, + ): ReadonlyMap => + importContextRecords(fragment.records, fragment.source.environmentId); + const importContextFragmentStable = useStableCallback(importContextFragment); // ------------------------------------------------------------------ // Sync refs back to parent @@ -3797,7 +3717,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Callbacks: prompt change // ------------------------------------------------------------------ - const expandComposerForEditorChange = useCallback(() => { + const expandComposerForEditorChange = () => { // Editor changes win over the momentum tail of the active scroll gesture. suppressActiveComposerScrollGesture( composerScrollGestureRef.current, @@ -3805,7 +3725,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) COMPOSER_SCROLL_GESTURE_RESET_MS, ); setIsComposerScrollCollapsed(false); - }, [setIsComposerScrollCollapsed]); + }; + const expandComposerForEditorChangeStable = useStableCallback(expandComposerForEditorChange); /** * Payloads for chips the prompt no longer references. History undo restores the @@ -3822,260 +3743,216 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) previewAnnotations: new Map(), }); - const onPromptChange = useCallback( - ( - nextPrompt: string, - nextCursor: number, - expandedCursor: number, - cursorAdjacentToMention: boolean, - contextIds: string[], - ) => { - expandComposerForEditorChange(); - if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { - if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; - setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), - ); - onChangeActivePendingUserInputCustomAnswer( - activePendingProgress.activeQuestion.id, - nextPrompt, - nextCursor, - expandedCursor, - cursorAdjacentToMention, - ); - return; - } - onTypingGuardDraftChange(); - promptRef.current = nextPrompt; - setPrompt(nextPrompt); - // Any edit ends browsing, even one later undone by hand: typing a - // character and deleting it leaves the text equal to the recall, and - // ArrowDown must move the caret then, not clear the composer. - if (promptHistoryPositionRef.current?.recalled !== nextPrompt) { - promptHistoryPositionRef.current = null; - } - const referenced = new Set(contextIds); - const retained = removedContextPayloadsRef.current; - - // An undone delete brings the reference back; restore the payload it points at. - const liveTerminalIds = new Set( - composerTerminalContexts.map((context) => terminalContextReference(context).contextId), + const onPromptChange = ( + nextPrompt: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + contextIds: string[], + ) => { + expandComposerForEditorChange(); + if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { + if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; + setComposerCursor(nextCursor); + setComposerTrigger( + cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); - const restoredTerminals = [...referenced].flatMap((contextId) => { - if (liveTerminalIds.has(contextId)) return []; - const context = retained.terminals.get(contextId); - return context ? [context] : []; - }); - const nextTerminals = [ - ...composerTerminalContexts.filter((context) => - referenced.has(terminalContextReference(context).contextId), - ), - ...restoredTerminals, - ]; - for (const context of composerTerminalContexts) { - const contextId = terminalContextReference(context).contextId; - if (!referenced.has(contextId)) retained.terminals.set(contextId, context); - } - if ( - nextTerminals.length !== composerTerminalContexts.length || - restoredTerminals.length > 0 - ) { - setComposerDraftTerminalContexts(composerDraftTarget, nextTerminals); - } - - const liveThreadIds = new Set( - composerThreadContexts.map((record) => record.contextId), + onChangeActivePendingUserInputCustomAnswer( + activePendingProgress.activeQuestion.id, + nextPrompt, + nextCursor, + expandedCursor, + cursorAdjacentToMention, ); - const restoredThreads = [...referenced].flatMap((contextId) => { - if (liveThreadIds.has(contextId)) return []; - const record = retained.threads.get(contextId); - return record ? [record] : []; - }); - const nextThreads = [ - ...composerThreadContexts.filter((record) => referenced.has(record.contextId)), - ...restoredThreads, - ]; - for (const record of composerThreadContexts) { - if (!referenced.has(record.contextId)) retained.threads.set(record.contextId, record); - } - if (nextThreads.length !== composerThreadContexts.length || restoredThreads.length > 0) { - setComposerDraftThreadContexts(composerDraftTarget, nextThreads); - } + return; + } + onTypingGuardDraftChange(); + promptRef.current = nextPrompt; + setPrompt(nextPrompt); + // Any edit ends browsing, even one later undone by hand: typing a + // character and deleting it leaves the text equal to the recall, and + // ArrowDown must move the caret then, not clear the composer. + if (promptHistoryPositionRef.current?.recalled !== nextPrompt) { + promptHistoryPositionRef.current = null; + } + const referenced = new Set(contextIds); + const retained = removedContextPayloadsRef.current; - for (const comment of composerReviewComments) { - const contextId = reviewCommentContextId(comment.id); - if (!referenced.has(contextId)) { - retained.reviewComments.set(contextId, comment); - removeComposerDraftReviewComment(composerDraftTarget, comment.id); - } + // An undone delete brings the reference back; restore the payload it points at. + const liveTerminalIds = new Set( + composerTerminalContexts.map((context) => terminalContextReference(context).contextId), + ); + const restoredTerminals = [...referenced].flatMap((contextId) => { + if (liveTerminalIds.has(contextId)) return []; + const context = retained.terminals.get(contextId); + return context ? [context] : []; + }); + const nextTerminals = [ + ...composerTerminalContexts.filter((context) => + referenced.has(terminalContextReference(context).contextId), + ), + ...restoredTerminals, + ]; + for (const context of composerTerminalContexts) { + const contextId = terminalContextReference(context).contextId; + if (!referenced.has(contextId)) retained.terminals.set(contextId, context); + } + if (nextTerminals.length !== composerTerminalContexts.length || restoredTerminals.length > 0) { + setComposerDraftTerminalContexts(composerDraftTarget, nextTerminals); + } + + const liveThreadIds = new Set(composerThreadContexts.map((record) => record.contextId)); + const restoredThreads = [...referenced].flatMap((contextId) => { + if (liveThreadIds.has(contextId)) return []; + const record = retained.threads.get(contextId); + return record ? [record] : []; + }); + const nextThreads = [ + ...composerThreadContexts.filter((record) => referenced.has(record.contextId)), + ...restoredThreads, + ]; + for (const record of composerThreadContexts) { + if (!referenced.has(record.contextId)) retained.threads.set(record.contextId, record); + } + if (nextThreads.length !== composerThreadContexts.length || restoredThreads.length > 0) { + setComposerDraftThreadContexts(composerDraftTarget, nextThreads); + } + + for (const comment of composerReviewComments) { + const contextId = reviewCommentContextId(comment.id); + if (!referenced.has(contextId)) { + retained.reviewComments.set(contextId, comment); + removeComposerDraftReviewComment(composerDraftTarget, comment.id); } - const liveReviewIds = new Set( - composerReviewComments.map((comment) => reviewCommentContextId(comment.id)), - ); - for (const contextId of referenced) { - if (liveReviewIds.has(contextId)) continue; - const comment = retained.reviewComments.get(contextId); - if (comment) { - addComposerDraftReviewComment(composerDraftTarget, comment, { appendReference: false }); - } + } + const liveReviewIds = new Set( + composerReviewComments.map((comment) => reviewCommentContextId(comment.id)), + ); + for (const contextId of referenced) { + if (liveReviewIds.has(contextId)) continue; + const comment = retained.reviewComments.get(contextId); + if (comment) { + addComposerDraftReviewComment(composerDraftTarget, comment, { appendReference: false }); } + } - const attachmentChanges = reconcileAttachmentContextReferences({ - referencedContextIds: referenced, - files: composerFiles, - images: composerImages, - previewAnnotations: composerPreviewAnnotations, - retained: removedAttachmentContextPayloadsRef.current, + const attachmentChanges = reconcileAttachmentContextReferences({ + referencedContextIds: referenced, + files: composerFiles, + images: composerImages, + previewAnnotations: composerPreviewAnnotations, + retained: removedAttachmentContextPayloadsRef.current, + }); + for (const annotationId of attachmentChanges.annotationIdsToRemove) { + // Keep the upload queue entry alive: undo restores the image that owns it. + removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId); + } + for (const restored of attachmentChanges.annotationsToRestore) { + if (restored.image) addComposerDraftImages(attachmentDraftTarget, [restored.image]); + addComposerDraftPreviewAnnotation(composerDraftTarget, restored.annotation, { + appendReference: false, }); - for (const annotationId of attachmentChanges.annotationIdsToRemove) { - // Keep the upload queue entry alive: undo restores the image that owns it. - removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId); - } - for (const restored of attachmentChanges.annotationsToRestore) { - if (restored.image) addComposerDraftImages(attachmentDraftTarget, [restored.image]); - addComposerDraftPreviewAnnotation(composerDraftTarget, restored.annotation, { - appendReference: false, - }); - } - for (const fileId of attachmentChanges.filesToRemove) { - // The retained File and upload are still sendable if the editor restores the chip. - removeComposerDraftFile(attachmentDraftTarget, fileId); - } - if (attachmentChanges.filesToRestore.length > 0) { - addComposerDraftFiles(attachmentDraftTarget, attachmentChanges.filesToRestore); - } - setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), - ); - }, - [ - activePendingProgress?.activeQuestion, - expandComposerForEditorChange, - onTypingGuardDraftChange, - pendingUserInputs.length, - onChangeActivePendingUserInputCustomAnswer, - promptRef, - setPrompt, - setComposerTrigger, - composerDraftTarget, - composerTerminalContexts, - setComposerDraftTerminalContexts, - composerThreadContexts, - setComposerDraftThreadContexts, - composerReviewComments, - composerPreviewAnnotations, - composerImages, - composerFiles, - removeComposerDraftReviewComment, - removeComposerDraftPreviewAnnotation, - removeComposerDraftFile, - addComposerDraftReviewComment, - addComposerDraftPreviewAnnotation, - addComposerDraftImages, - addComposerDraftFiles, - attachmentDraftTarget, - ], - ); + } + for (const fileId of attachmentChanges.filesToRemove) { + // The retained File and upload are still sendable if the editor restores the chip. + removeComposerDraftFile(attachmentDraftTarget, fileId); + } + if (attachmentChanges.filesToRestore.length > 0) { + addComposerDraftFiles(attachmentDraftTarget, attachmentChanges.filesToRestore); + } + setComposerCursor(nextCursor); + setComposerTrigger( + cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), + ); + }; + const onPromptChangeStable = useStableCallback(onPromptChange); // ------------------------------------------------------------------ // Callbacks: prompt replacement / menu // ------------------------------------------------------------------ - const applyPromptReplacement = useCallback( - ( - rangeStart: number, - rangeEnd: number, - replacement: string, - options?: { - expectedText?: string; - expandedCursorAfterReplace?: number; - focusEditorAfterReplace?: boolean; - citationComment?: { - start: number; - sourceAnchor: AssistantCitationSourceAnchor; - insertedSpaces: ComposerCitationCommentRequest["insertedSpaces"]; - }; - }, - ): boolean => { - if ( - activePendingUserInput && - activePendingProgress?.activeQuestion?.allowCustomAnswer === false - ) { - return false; - } - const currentText = promptRef.current; - const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); - const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); - if ( - options?.expectedText !== undefined && - currentText.slice(safeStart, safeEnd) !== options.expectedText - ) { - return false; - } - const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); - const nextCursor = collapseExpandedComposerCursor( - next.text, - options?.expandedCursorAfterReplace ?? next.cursor, - isLiteralPendingAnswer, - ); - const nextExpandedCursor = expandCollapsedComposerCursor( + const applyPromptReplacement = ( + rangeStart: number, + rangeEnd: number, + replacement: string, + options?: { + expectedText?: string; + expandedCursorAfterReplace?: number; + focusEditorAfterReplace?: boolean; + citationComment?: { + start: number; + sourceAnchor: AssistantCitationSourceAnchor; + insertedSpaces: ComposerCitationCommentRequest["insertedSpaces"]; + }; + }, + ): boolean => { + if ( + activePendingUserInput && + activePendingProgress?.activeQuestion?.allowCustomAnswer === false + ) { + return false; + } + const currentText = promptRef.current; + const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); + const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); + if ( + options?.expectedText !== undefined && + currentText.slice(safeStart, safeEnd) !== options.expectedText + ) { + return false; + } + const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + const nextCursor = collapseExpandedComposerCursor( + next.text, + options?.expandedCursorAfterReplace ?? next.cursor, + isLiteralPendingAnswer, + ); + const nextExpandedCursor = expandCollapsedComposerCursor( + next.text, + nextCursor, + isLiteralPendingAnswer, + ); + if (options?.citationComment) { + composerEditorRef.current?.requestCitationComment({ + previousValue: currentText, + value: next.text, + citationStart: options.citationComment.start, + sourceAnchor: options.citationComment.sourceAnchor, + insertedSpaces: options.citationComment.insertedSpaces, + }); + } + promptRef.current = next.text; + const activePendingQuestion = activePendingProgress?.activeQuestion; + if (activePendingQuestion && activePendingUserInput) { + onChangeActivePendingUserInputCustomAnswer( + activePendingQuestion.id, next.text, nextCursor, + nextExpandedCursor, isLiteralPendingAnswer, ); - if (options?.citationComment) { - composerEditorRef.current?.requestCitationComment({ - previousValue: currentText, - value: next.text, - citationStart: options.citationComment.start, - sourceAnchor: options.citationComment.sourceAnchor, - insertedSpaces: options.citationComment.insertedSpaces, - }); - } - promptRef.current = next.text; - const activePendingQuestion = activePendingProgress?.activeQuestion; - if (activePendingQuestion && activePendingUserInput) { - onChangeActivePendingUserInputCustomAnswer( - activePendingQuestion.id, - next.text, - nextCursor, - nextExpandedCursor, - isLiteralPendingAnswer, - ); - } else { - setPrompt(next.text); - onTypingGuardDraftChange(); - } - setComposerCursor(nextCursor); - setComposerTrigger( - isLiteralPendingAnswer ? null : detectComposerTrigger(next.text, nextExpandedCursor), - ); - if (options?.focusEditorAfterReplace !== false) { - window.requestAnimationFrame(() => { - // Type-to-focus routes only the first key through here; once the - // controlled update focuses the editor, later keys land natively. - // Skip the deferred caret placement when the draft has moved on, - // or it drags the caret back behind what was typed since. - if (promptRef.current !== next.text) return; - composerEditorRef.current?.focusAt(nextCursor); - }); - } - return true; - }, - [ - activePendingProgress?.activeQuestion, - activePendingUserInput, - isLiteralPendingAnswer, - onChangeActivePendingUserInputCustomAnswer, - onTypingGuardDraftChange, - promptRef, - setPrompt, - setComposerTrigger, - ], - ); + } else { + setPrompt(next.text); + onTypingGuardDraftChange(); + } + setComposerCursor(nextCursor); + setComposerTrigger( + isLiteralPendingAnswer ? null : detectComposerTrigger(next.text, nextExpandedCursor), + ); + if (options?.focusEditorAfterReplace !== false) { + window.requestAnimationFrame(() => { + // Type-to-focus routes only the first key through here; once the + // controlled update focuses the editor, later keys land natively. + // Skip the deferred caret placement when the draft has moved on, + // or it drags the caret back behind what was typed since. + if (promptRef.current !== next.text) return; + composerEditorRef.current?.focusAt(nextCursor); + }); + } + return true; + }; + const applyPromptReplacementStable = useStableCallback(applyPromptReplacement); - const readComposerSnapshot = useCallback((): { + const readComposerSnapshot = (): { value: string; cursor: number; expandedCursor: number; @@ -4095,9 +3972,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ), contextIds: isLiteralPendingAnswer ? [] : collectInlineContextIds(promptRef.current), }; - }, [composerCursor, isLiteralPendingAnswer, promptRef]); + }; + const readComposerSnapshotStable = useStableCallback(readComposerSnapshot); - const resolveActiveComposerTrigger = useCallback((): { + const resolveActiveComposerTrigger = (): { snapshot: { value: string; cursor: number; expandedCursor: number }; trigger: ComposerTrigger | null; } => { @@ -4108,200 +3986,167 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? null : resolveComposerTrigger(detectComposerTrigger(snapshot.value, snapshot.expandedCursor)), }; - }, [isLiteralPendingAnswer, readComposerSnapshot, resolveComposerTrigger]); + }; const { onUsageLimitsCommand } = props; - const onSelectComposerItem = useCallback( - (item: ComposerCommandItem) => { - if (composerSelectLockRef.current) return; - composerSelectLockRef.current = true; - window.requestAnimationFrame(() => { - composerSelectLockRef.current = false; + const onSelectComposerItem = (item: ComposerCommandItem) => { + if (composerSelectLockRef.current) return; + composerSelectLockRef.current = true; + window.requestAnimationFrame(() => { + composerSelectLockRef.current = false; + }); + const { snapshot, trigger } = resolveActiveComposerTrigger(); + if (!trigger) return; + if (item.type === "path") { + const replacement = `${serializeComposerFileLink(item.path)} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), }); - const { snapshot, trigger } = resolveActiveComposerTrigger(); - if (!trigger) return; - if (item.type === "path") { - const replacement = `${serializeComposerFileLink(item.path)} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); - if (applied) { - setComposerHighlightedItemId(null); - } - return; + if (applied) { + setComposerHighlightedItemId(null); } - if (item.type === "slash-command") { - if (item.command === "model") { - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - focusEditorAfterReplace: false, - }); - if (applied) { - setComposerHighlightedItemId(null); - setIsComposerModelPickerOpen(true); - } - return; - } - if (!planModeUiEnabled) return; - void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + return; + } + if (item.type === "slash-command") { + if (item.command === "model") { const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, }); if (applied) { setComposerHighlightedItemId(null); + setIsComposerModelPickerOpen(true); } return; } - if (item.type === "provider-slash-command") { - if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) { - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), - focusEditorAfterReplace: false, - }); - if (applied) { - setComposerHighlightedItemId(null); - onUsageLimitsCommand(); - } - return; - } - const replacement = `/${item.command.name} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); - if (applied) { - setComposerHighlightedItemId(null); - } - return; + if (!planModeUiEnabled) return; + void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); } - if (item.type === "skill") { - const replacement = `$${item.skill.name} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); + return; + } + if (item.type === "provider-slash-command") { + if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, + }); if (applied) { setComposerHighlightedItemId(null); + onUsageLimitsCommand(); } return; } - if (item.type === "pull-request") { - if ( - trigger.kind !== "pull-request" || - !composerMenuItemsRef.current.some((candidate) => candidate.id === item.id) - ) { - return; - } - const comment = buildPullRequestReferenceContext(item.pullRequest); - const replacement = `${formatInlineContextReference( - reviewCommentContextReference(comment), - )} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); - if (applied) { - addComposerDraftReviewComment(composerDraftTarget, comment, { - appendReference: false, - }); - setComposerHighlightedItemId(null); - } - return; + const replacement = `/${item.command.name} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); } - if (item.type === "thread") { - if (trigger.kind !== "path") return; - const shell = readThreadShell(item.thread); - if (!shell) return; - const record = threadContextRecord(item.thread, shell.title); - const replacement = `${formatInlineContextReference(threadContextReference(record))} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement( - trigger.rangeStart, - replacementRangeEnd, - replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, - ); - if (applied) { - addComposerDraftThreadContexts(attachmentDraftTarget, [record], { - appendReference: false, - }); - setComposerHighlightedItemId(null); - } + return; + } + if (item.type === "skill") { + const replacement = `$${item.skill.name} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "pull-request") { + if ( + trigger.kind !== "pull-request" || + !composerMenuItemsRef.current.some((candidate) => candidate.id === item.id) + ) { return; } - }, - [ - addComposerDraftReviewComment, - addComposerDraftThreadContexts, - applyPromptReplacement, - attachmentDraftTarget, - composerDraftTarget, - handleInteractionModeChange, - planModeUiEnabled, - onUsageLimitsCommand, - resolveActiveComposerTrigger, - ], - ); + const comment = buildPullRequestReferenceContext(item.pullRequest); + const replacement = `${formatInlineContextReference( + reviewCommentContextReference(comment), + )} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (applied) { + addComposerDraftReviewComment(composerDraftTarget, comment, { + appendReference: false, + }); + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "thread") { + if (trigger.kind !== "path") return; + const shell = readThreadShell(item.thread); + if (!shell) return; + const record = threadContextRecord(item.thread, shell.title); + const replacement = `${formatInlineContextReference(threadContextReference(record))} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (applied) { + addComposerDraftThreadContexts(attachmentDraftTarget, [record], { + appendReference: false, + }); + setComposerHighlightedItemId(null); + } + return; + } + }; + const onSelectComposerItemStable = useStableCallback(onSelectComposerItem); - const onComposerMenuItemHighlighted = useCallback( - (itemId: string | null) => { - setComposerHighlightedItemId(itemId); - setComposerHighlightedSearchKey(composerMenuSearchKey); - }, - [composerMenuSearchKey], - ); + const onComposerMenuItemHighlighted = (itemId: string | null) => { + setComposerHighlightedItemId(itemId); + setComposerHighlightedSearchKey(composerMenuSearchKey); + }; + const onComposerMenuItemHighlightedStable = useStableCallback(onComposerMenuItemHighlighted); - const nudgeComposerMenuHighlight = useCallback( - (key: "ArrowDown" | "ArrowUp") => { - if (composerMenuItems.length === 0) return; - const highlightedIndex = composerMenuItems.findIndex( - (item) => item.id === composerHighlightedItemId, - ); - const normalizedIndex = - highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; - const offset = key === "ArrowDown" ? 1 : -1; - const nextIndex = - (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; - const nextItem = composerMenuItems[nextIndex]; - setComposerHighlightedItemId(nextItem?.id ?? null); - }, - [composerHighlightedItemId, composerMenuItems], - ); + const nudgeComposerMenuHighlight = (key: "ArrowDown" | "ArrowUp") => { + if (composerMenuItems.length === 0) return; + const highlightedIndex = composerMenuItems.findIndex( + (item) => item.id === composerHighlightedItemId, + ); + const normalizedIndex = highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; + const offset = key === "ArrowDown" ? 1 : -1; + const nextIndex = + (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; + const nextItem = composerMenuItems[nextIndex]; + setComposerHighlightedItemId(nextItem?.id ?? null); + }; - const blurMobileComposerAfterSend = useCallback(() => { + const blurMobileComposerAfterSend = () => { if (!isMobileViewport) return; if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); @@ -4312,9 +4157,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeElement.blur(); } setIsComposerFocused(false); - }, [isMobileViewport, setIsComposerFocused]); + }; - const shouldBlurMobileComposerOnSubmit = useCallback(() => { + const shouldBlurMobileComposerOnSubmit = () => { if (!isMobileViewport) return false; if ( isSendBusy || @@ -4330,120 +4175,87 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } return showPlanFollowUpPrompt || composerSendState.hasSendableContent; - }, [ - activePendingProgress, - activePendingResolvedAnswers, - composerSendState.hasSendableContent, - environmentUnavailable, - isConnecting, - isMobileViewport, - isSendBusy, - isSendDisabled, - noProviderAvailable, - phase, - showPlanFollowUpPrompt, - ]); + }; - const submitComposer = useCallback( - ( - event?: { preventDefault: () => void }, - dispatchMode?: ComposerDispatchMode, - submissionIntent?: ComposerSubmissionIntent, - ) => { - if ( - noProviderAvailable || - isSendDisabled || - !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) - ) { - event?.preventDefault(); - return; - } - // A send while a pasted image is still compressing would strand that - // image: the turn snapshot wouldn't include it, and it would surface - // in the *next* draft instead. Only oversized images hit this — small - // files clear the pending counter within a microtask. - if ( - activeThreadId && - (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0 - ) { - event?.preventDefault(); - toastManager.add({ - type: "info", - title: "Still compressing a pasted image.", - description: "Send again once its thumbnail appears.", - }); - return; - } - // A pasted chip's bytes arrive over the network, so the same hazard applies for longer: - // sending now would snapshot a chip with no attachment behind it. - if (pendingDraftWork.has(attachmentTargetKey)) { - event?.preventDefault(); - toastManager.add({ - type: "info", - title: "Still bringing attached context into this message.", - description: "Send again once its chip resolves.", - }); - return; - } - const submission = submitComposerDraft({ - prompt: promptRef.current, - submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", - event, - onSend: (sendEvent) => { - // ChatView reports its final composed-input preflight through the - // composer handle before its first asynchronous send step. - providerInputRejectedRef.current = false; - onSend( - sendEvent, - dispatchMode ?? - resolveComposerDispatchMode({ - running: phase === "running", - alternateModifier: false, - activeTurnDefault: settings.followUpBehavior, - }), - submissionIntent, - ); - return !providerInputRejectedRef.current; - }, + const submitComposer = ( + event?: { preventDefault: () => void }, + dispatchMode?: ComposerDispatchMode, + submissionIntent?: ComposerSubmissionIntent, + ) => { + if ( + noProviderAvailable || + isSendDisabled || + !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) + ) { + event?.preventDefault(); + return; + } + // A send while a pasted image is still compressing would strand that + // image: the turn snapshot wouldn't include it, and it would surface + // in the *next* draft instead. Only oversized images hit this — small + // files clear the pending counter within a microtask. + if (activeThreadId && (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0) { + event?.preventDefault(); + toastManager.add({ + type: "info", + title: "Still compressing a pasted image.", + description: "Send again once its thumbnail appears.", }); - setComposerSubmissionError(submission.validationMessage); - if (!submission.didDispatch) return; - onTypingGuardSend(); - if (shouldBlurMobileComposerOnSubmit()) { - blurMobileComposerAfterSend(); - } - }, - [ - activeThreadId, - activePendingProgress, - attachmentTargetKey, - onTypingGuardSend, - blurMobileComposerAfterSend, - environmentId, - isSendDisabled, - noProviderAvailable, - onSend, - settings.followUpBehavior, - phase, - promptRef, - shouldBlurMobileComposerOnSubmit, - ], - ); - const handleSubmitMessage = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - submitComposer( - event, - resolveComposerDispatchMode({ - running: phase === "running", - alternateModifier: event.metaKey || event.ctrlKey, - activeTurnDefault: settings.followUpBehavior, - }), - ); - }, - [phase, settings.followUpBehavior, submitComposer], - ); - const submitCitationAndSend = useCallback(() => { + return; + } + // A pasted chip's bytes arrive over the network, so the same hazard applies for longer: + // sending now would snapshot a chip with no attachment behind it. + if (pendingDraftWork.has(attachmentTargetKey)) { + event?.preventDefault(); + toastManager.add({ + type: "info", + title: "Still bringing attached context into this message.", + description: "Send again once its chip resolves.", + }); + return; + } + const submission = submitComposerDraft({ + prompt: promptRef.current, + submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", + event, + onSend: (sendEvent) => { + // ChatView reports its final composed-input preflight through the + // composer handle before its first asynchronous send step. + providerInputRejectedRef.current = false; + onSend( + sendEvent, + dispatchMode ?? + resolveComposerDispatchMode({ + running: phase === "running", + alternateModifier: false, + activeTurnDefault: settings.followUpBehavior, + }), + submissionIntent, + ); + return !providerInputRejectedRef.current; + }, + }); + setComposerSubmissionError(submission.validationMessage); + if (!submission.didDispatch) return; + onTypingGuardSend(); + if (shouldBlurMobileComposerOnSubmit()) { + blurMobileComposerAfterSend(); + } + }; + const submitComposerStable = useStableCallback(submitComposer); + const handleSubmitMessage = (event: React.MouseEvent) => { + event.preventDefault(); + submitComposer( + event, + resolveComposerDispatchMode({ + running: phase === "running", + alternateModifier: event.metaKey || event.ctrlKey, + activeTurnDefault: settings.followUpBehavior, + }), + ); + }; + const handleSubmitMessageStable = useStableCallback(handleSubmitMessage); + const submitCitationAndSend = () => { submitComposer( undefined, resolveComposerDispatchMode({ @@ -4452,18 +4264,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeTurnDefault: settings.followUpBehavior, }), ); - }, [phase, settings.followUpBehavior, submitComposer]); + }; + const submitCitationAndSendStable = useStableCallback(submitCitationAndSend); // A question or approval took the editor while a citation comment was // unsaved. Keep the comment in the prompt that comes back afterward. - const saveCitationDraftToPrompt = useCallback( - (replacedValue: string, savedValue: string) => { - if (replacedValue !== prompt) return; - if (promptRef.current === replacedValue) promptRef.current = savedValue; - setPrompt(savedValue); - }, - [prompt, promptRef, setPrompt], - ); - const compactThreadContext = useCallback(() => { + const saveCitationDraftToPrompt = (replacedValue: string, savedValue: string) => { + if (replacedValue !== prompt) return; + if (promptRef.current === replacedValue) promptRef.current = savedValue; + setPrompt(savedValue); + }; + const saveCitationDraftToPromptStable = useStableCallback(saveCitationDraftToPrompt); + const compactThreadContext = () => { if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || compactDisabled || @@ -4478,20 +4289,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } onCompactContext(); - }, [ - activePendingApproval, - activeThreadId, - compactDisabled, - composerDraftTarget, - environmentId, - isConnecting, - isSendBusy, - noProviderAvailable, - onCompactContext, - pendingUserInputs.length, - phase, - ]); - const expandMobileComposer = useCallback(() => { + }; + const compactThreadContextStable = useStableCallback(compactThreadContext); + const expandMobileComposer = () => { if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; @@ -4512,7 +4312,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mobileComposerExpandInFlightRef.current = false; }); }); - }, [setIsComposerFocused]); + }; + const expandMobileComposerStable = useStableCallback(expandMobileComposer); // ------------------------------------------------------------------ // Prompt history (ArrowUp / ArrowDown) @@ -4530,68 +4331,55 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptHistoryPositionRef.current = null; }, [promptHistoryTargetKey]); - const replacePromptFromHistory = useCallback( - (nextPrompt: string) => { - promptRef.current = nextPrompt; - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); - setComposerTrigger(null); - setComposerHighlightedItemId(null); - }, - [composerDraftTarget, promptRef, setComposerDraftPrompt, setComposerTrigger], - ); + const replacePromptFromHistory = (nextPrompt: string) => { + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + setComposerHighlightedItemId(null); + }; - const navigatePromptHistory = useCallback( - (direction: "backward" | "forward", event: KeyboardEvent): boolean => { - if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { - return false; - } - if (isComposerApprovalState || pendingUserInputs.length > 0) return false; - // A composer holding an image, file, picked element, preview - // annotation, or review comment is not empty. Recalling text into it - // would send the old prompt with the new context, which is never what - // ArrowUp meant. - if ( - composerImagesRef.current.length > 0 || - composerFilesRef.current.length > 0 || - composerTerminalContextsRef.current.length > 0 || - composerPreviewAnnotations.length > 0 || - composerReviewComments.length > 0 - ) { - return false; - } - // A typed draft with no active recall can never step, so skip the - // layout read and the entry build for that common case. - if (promptHistoryPositionRef.current === null && promptRef.current.length > 0) { - return false; - } - const editor = composerEditorRef.current; - if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { - return false; - } - const step = stepComposerPromptHistory({ - direction, - entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), - position: promptHistoryPositionRef.current, - currentPrompt: promptRef.current, - }); - if (!step) return false; - promptHistoryPositionRef.current = step.position; - replacePromptFromHistory(step.prompt); - return true; - }, - [ - composerTerminalContextsRef, - composerFilesRef, - composerImagesRef, - composerPreviewAnnotations.length, - composerReviewComments.length, - isComposerApprovalState, - pendingUserInputs.length, - promptRef, - replacePromptFromHistory, - ], - ); + const navigatePromptHistory = ( + direction: "backward" | "forward", + event: KeyboardEvent, + ): boolean => { + if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { + return false; + } + if (isComposerApprovalState || pendingUserInputs.length > 0) return false; + // A composer holding an image, file, picked element, preview + // annotation, or review comment is not empty. Recalling text into it + // would send the old prompt with the new context, which is never what + // ArrowUp meant. + if ( + composerImagesRef.current.length > 0 || + composerFilesRef.current.length > 0 || + composerTerminalContextsRef.current.length > 0 || + composerPreviewAnnotations.length > 0 || + composerReviewComments.length > 0 + ) { + return false; + } + // A typed draft with no active recall can never step, so skip the + // layout read and the entry build for that common case. + if (promptHistoryPositionRef.current === null && promptRef.current.length > 0) { + return false; + } + const editor = composerEditorRef.current; + if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { + return false; + } + const step = stepComposerPromptHistory({ + direction, + entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), + position: promptHistoryPositionRef.current, + currentPrompt: promptRef.current, + }); + if (!step) return false; + promptHistoryPositionRef.current = step.position; + replacePromptFromHistory(step.prompt); + return true; + }; // ------------------------------------------------------------------ // Callbacks: command key @@ -4701,7 +4489,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, []); /** Briefly highlight the badge so the save registers without a flourish. */ - const pulseStashBadge = useCallback(() => { + const pulseStashBadge = () => { stashPulseKeyRef.current += 1; setStashPulse({ key: stashPulseKeyRef.current, active: true }); if (stashPulseTimeoutRef.current !== null) { @@ -4711,334 +4499,318 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) stashPulseTimeoutRef.current = null; setStashPulse((current) => ({ ...current, active: false })); }, 1200); - }, []); - - const restoreStashEntry = useCallback( - async (menuEntry: PromptStashEntry) => { - const filesToVerify = menuEntry.files ?? []; - if (filesToVerify.some((file) => file.environmentId !== environmentId)) { - toastManager.add({ - type: "error", - title: "Stashed files belong to another environment", - description: "Restore this prompt in the environment that received its files.", - }); - return; - } - setIsStashMenuOpen(false); + }; - // The server sweeps pending uploads after 24 hours, so ask before - // reattaching. An expired upload restores as a needs-reattach row - // instead of a reference the next send would fail to verify. Verify - // BEFORE taking: the take removes the entry from durable storage, and a - // tab closed during this await must still find it there after reload. - const verifications = await Promise.all( - filesToVerify.map((file) => - verifyStashedAttachmentUpload({ environmentId, attachmentId: file.attachmentId }), - ), - ); - const expiredAttachmentIds = new Set( - filesToVerify - .filter((_, index) => verifications[index]?.status === "missing") - .map((file) => file.attachmentId), - ); + const restoreStashEntry = async (menuEntry: PromptStashEntry) => { + const filesToVerify = menuEntry.files ?? []; + if (filesToVerify.some((file) => file.environmentId !== environmentId)) { + toastManager.add({ + type: "error", + title: "Stashed files belong to another environment", + description: "Restore this prompt in the environment that received its files.", + }); + return; + } + setIsStashMenuOpen(false); - // A thread switch during the verify await would mix the new thread's - // prompt with this invocation's captured target. Nothing was taken yet, - // so abort and leave the entry restorable where the user now is. - if ( - isRevertingCheckpointRef.current || - composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current - ) { - return; - } + // The server sweeps pending uploads after 24 hours, so ask before + // reattaching. An expired upload restores as a needs-reattach row + // instead of a reference the next send would fail to verify. Verify + // BEFORE taking: the take removes the entry from durable storage, and a + // tab closed during this await must still find it there after reload. + const verifications = await Promise.all( + filesToVerify.map((file) => + verifyStashedAttachmentUpload({ environmentId, attachmentId: file.attachmentId }), + ), + ); + const expiredAttachmentIds = new Set( + filesToVerify + .filter((_, index) => verifications[index]?.status === "missing") + .map((file) => file.attachmentId), + ); - // The take is also the double-activation guard (click + Enter): the - // second caller finds the entry gone and stops here. - const { entry, durable } = takeStashEntry(menuEntry.id); - if (!entry) return; - if (!durable) { - toastManager.add({ - type: "warning", - title: "Restored prompt may reappear in the stash", - description: - "Browser storage rejected the update, so this entry could still be there after a reload.", - data: { hideCopyButton: true }, - }); - } + // A thread switch during the verify await would mix the new thread's + // prompt with this invocation's captured target. Nothing was taken yet, + // so abort and leave the entry restorable where the user now is. + if ( + isRevertingCheckpointRef.current || + composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current + ) { + return; + } - const rewrittenContextIds = entry.records - ? importContextRecords(entry.records, null) - : new Map(); - const restoredPrompt = replaceComposerContextReferences(entry.prompt, (reference) => { - const contextId = rewrittenContextIds.get(reference.contextId); - return contextId - ? formatInlineContextReference({ - ...reference, - contextId, - kind: reference.kind === "element" ? "preview-annotation" : reference.kind, - }) - : reference.source; + // The take is also the double-activation guard (click + Enter): the + // second caller finds the entry gone and stops here. + const { entry, durable } = takeStashEntry(menuEntry.id); + if (!entry) return; + if (!durable) { + toastManager.add({ + type: "warning", + title: "Restored prompt may reappear in the stash", + description: + "Browser storage rejected the update, so this entry could still be there after a reload.", + data: { hideCopyButton: true }, }); - const currentPrompt = promptRef.current; - // An image-only stash must not append blank lines to whatever is - // already in the composer. - const nextPrompt = - restoredPrompt.length === 0 - ? currentPrompt - : currentPrompt.trim().length - ? `${currentPrompt.replace(/\s+$/, "")}\n\n${restoredPrompt}` - : restoredPrompt; - let promptChanged = nextPrompt !== currentPrompt; - if (promptChanged) { - promptRef.current = nextPrompt; - setComposerDraftPrompt(composerDraftTarget, nextPrompt); - setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); - setComposerTrigger(null); - } + } - let unrestoredFileNames: string[] = []; - const expiredFileNames: string[] = []; - let restoredFileCount = 0; - const stashedFiles = entry.files ?? []; - if (stashedFiles.length > 0) { - const composerFilesNow = composerFilesRef.current; - const existingFileIds = new Set(composerFilesNow.map((file) => file.id)); - const retainedUploadIds = new Set( - composerFilesNow.flatMap((file) => - file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [], - ), - ); - const existingFileKeys = new Set(composerFilesNow.map(composerFileDedupKey)); - const reattachMarkers = composerFilesNow.filter(composerFileNeedsReattach); - const restoredMarkerIds = new Set(); - const duplicateFiles: PersistedComposerFileAttachment[] = []; - const markerReplacements: ComposerFileAttachment[] = []; - const appendedFiles: ComposerFileAttachment[] = []; - for (const file of stashedFiles) { - const expired = expiredAttachmentIds.has(file.attachmentId); - const key = composerFileDedupKey(file); - const restored: ComposerFileAttachment = { - type: "file", - id: file.id, - name: file.name, - mimeType: file.mimeType, - sizeBytes: file.sizeBytes, - file: null, - ...(file.source ? { source: file.source } : {}), - // An expired upload carries no ids, so it hydrates as a - // needs-reattach row and the "Attach again" flow takes over. - ...(expired - ? {} - : { uploadedAttachmentId: file.attachmentId, uploadEnvironmentId: environmentId }), - }; - if (existingFileIds.has(file.id)) { - if (!expired && !retainedUploadIds.has(file.attachmentId)) { - duplicateFiles.push(file); - } - continue; - } - const reattachMarker = reattachMarkers.find( - (marker) => - !restoredMarkerIds.has(marker.id) && composerFileMatchesReattachMarker(marker, file), - ); - if (reattachMarker) { - restoredMarkerIds.add(reattachMarker.id); - existingFileIds.add(file.id); - existingFileKeys.add(key); - if (expired) { - expiredFileNames.push(file.name); - } else { - retainedUploadIds.add(file.attachmentId); - markerReplacements.push(restored); - } - continue; - } - if (existingFileKeys.has(key)) { - if (!expired && !retainedUploadIds.has(file.attachmentId)) { - duplicateFiles.push(file); - } - continue; + const rewrittenContextIds = entry.records + ? importContextRecords(entry.records, null) + : new Map(); + const restoredPrompt = replaceComposerContextReferences(entry.prompt, (reference) => { + const contextId = rewrittenContextIds.get(reference.contextId); + return contextId + ? formatInlineContextReference({ + ...reference, + contextId, + kind: reference.kind === "element" ? "preview-annotation" : reference.kind, + }) + : reference.source; + }); + const currentPrompt = promptRef.current; + // An image-only stash must not append blank lines to whatever is + // already in the composer. + const nextPrompt = + restoredPrompt.length === 0 + ? currentPrompt + : currentPrompt.trim().length + ? `${currentPrompt.replace(/\s+$/, "")}\n\n${restoredPrompt}` + : restoredPrompt; + let promptChanged = nextPrompt !== currentPrompt; + if (promptChanged) { + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + } + + let unrestoredFileNames: string[] = []; + const expiredFileNames: string[] = []; + let restoredFileCount = 0; + const stashedFiles = entry.files ?? []; + if (stashedFiles.length > 0) { + const composerFilesNow = composerFilesRef.current; + const existingFileIds = new Set(composerFilesNow.map((file) => file.id)); + const retainedUploadIds = new Set( + composerFilesNow.flatMap((file) => + file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [], + ), + ); + const existingFileKeys = new Set(composerFilesNow.map(composerFileDedupKey)); + const reattachMarkers = composerFilesNow.filter(composerFileNeedsReattach); + const restoredMarkerIds = new Set(); + const duplicateFiles: PersistedComposerFileAttachment[] = []; + const markerReplacements: ComposerFileAttachment[] = []; + const appendedFiles: ComposerFileAttachment[] = []; + for (const file of stashedFiles) { + const expired = expiredAttachmentIds.has(file.attachmentId); + const key = composerFileDedupKey(file); + const restored: ComposerFileAttachment = { + type: "file", + id: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + file: null, + ...(file.source ? { source: file.source } : {}), + // An expired upload carries no ids, so it hydrates as a + // needs-reattach row and the "Attach again" flow takes over. + ...(expired + ? {} + : { uploadedAttachmentId: file.attachmentId, uploadEnvironmentId: environmentId }), + }; + if (existingFileIds.has(file.id)) { + if (!expired && !retainedUploadIds.has(file.attachmentId)) { + duplicateFiles.push(file); } + continue; + } + const reattachMarker = reattachMarkers.find( + (marker) => + !restoredMarkerIds.has(marker.id) && composerFileMatchesReattachMarker(marker, file), + ); + if (reattachMarker) { + restoredMarkerIds.add(reattachMarker.id); existingFileIds.add(file.id); existingFileKeys.add(key); if (expired) { expiredFileNames.push(file.name); } else { retainedUploadIds.add(file.attachmentId); + markerReplacements.push(restored); } - appendedFiles.push(restored); + continue; } - const capacity = remainingComposerAttachmentSlots( - composerImagesRef.current.length + composerFilesNow.length, - pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0, - ); - // Marker replacements reuse their marker's slot; only appended files - // consume capacity. - const filesToAppend = appendedFiles.slice(0, capacity); - const skippedFiles = appendedFiles.slice(capacity); - unrestoredFileNames = skippedFiles.map((file) => file.name); - // A non-durable take can resurrect the stash entry after a reload; - // deleting these uploads would leave it pointing at nothing. - if (durable) { - for (const file of duplicateFiles) { + if (existingFileKeys.has(key)) { + if (!expired && !retainedUploadIds.has(file.attachmentId)) { + duplicateFiles.push(file); + } + continue; + } + existingFileIds.add(file.id); + existingFileKeys.add(key); + if (expired) { + expiredFileNames.push(file.name); + } else { + retainedUploadIds.add(file.attachmentId); + } + appendedFiles.push(restored); + } + const capacity = remainingComposerAttachmentSlots( + composerImagesRef.current.length + composerFilesNow.length, + pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0, + ); + // Marker replacements reuse their marker's slot; only appended files + // consume capacity. + const filesToAppend = appendedFiles.slice(0, capacity); + const skippedFiles = appendedFiles.slice(capacity); + unrestoredFileNames = skippedFiles.map((file) => file.name); + // A non-durable take can resurrect the stash entry after a reload; + // deleting these uploads would leave it pointing at nothing. + if (durable) { + for (const file of duplicateFiles) { + releasePersistedAttachmentUpload({ + id: file.id, + environmentId, + attachmentId: file.attachmentId, + }); + } + for (const file of skippedFiles) { + if (file.uploadedAttachmentId) { releasePersistedAttachmentUpload({ id: file.id, environmentId, - attachmentId: file.attachmentId, + attachmentId: file.uploadedAttachmentId, }); } - for (const file of skippedFiles) { - if (file.uploadedAttachmentId) { - releasePersistedAttachmentUpload({ - id: file.id, - environmentId, - attachmentId: file.uploadedAttachmentId, - }); - } - } } - const restoredFiles = [...markerReplacements, ...filesToAppend]; - if (restoredFiles.length > 0) { - addComposerDraftFiles(composerDraftTarget, restoredFiles, { appendReference: true }); - const restoredFilePrompt = getComposerDraft(composerDraftTarget)?.prompt; - if (restoredFilePrompt !== undefined && restoredFilePrompt !== promptRef.current) { - promptRef.current = restoredFilePrompt; - setComposerCursor( - collapseExpandedComposerCursor(restoredFilePrompt, restoredFilePrompt.length), - ); - setComposerTrigger(null); - promptChanged = true; - } - restoredFileCount = filesToAppend.length; + } + const restoredFiles = [...markerReplacements, ...filesToAppend]; + if (restoredFiles.length > 0) { + addComposerDraftFiles(composerDraftTarget, restoredFiles, { appendReference: true }); + const restoredFilePrompt = getComposerDraft(composerDraftTarget)?.prompt; + if (restoredFilePrompt !== undefined && restoredFilePrompt !== promptRef.current) { + promptRef.current = restoredFilePrompt; + setComposerCursor( + collapseExpandedComposerCursor(restoredFilePrompt, restoredFilePrompt.length), + ); + setComposerTrigger(null); + promptChanged = true; } + restoredFileCount = filesToAppend.length; } + } - let unrestoredImageNames: string[] = []; - if (entry.attachments.length > 0) { - const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); - // The draft store also dedupes by mimeType+sizeBytes+name, so filter - // on the same key here. Counting a duplicate against capacity would - // burn a slot the store then refuses to fill, pushing a genuinely - // unique image into the overflow list for nothing. - const existingDedupKeys = new Set( - composerImagesRef.current.map( - (image) => `${image.mimeType}\0${image.sizeBytes}\0${image.name}`, + let unrestoredImageNames: string[] = []; + if (entry.attachments.length > 0) { + const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); + // The draft store also dedupes by mimeType+sizeBytes+name, so filter + // on the same key here. Counting a duplicate against capacity would + // burn a slot the store then refuses to fill, pushing a genuinely + // unique image into the overflow list for nothing. + const existingDedupKeys = new Set( + composerImagesRef.current.map( + (image) => `${image.mimeType}\0${image.sizeBytes}\0${image.name}`, + ), + ); + const capacity = remainingComposerAttachmentSlots( + composerImagesRef.current.length + composerFilesRef.current.length + restoredFileCount, + pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0, + ); + const pending = entry.attachments.filter( + (attachment) => + !existingIds.has(attachment.id) && + !existingDedupKeys.has( + `${attachment.mimeType}\0${attachment.sizeBytes}\0${attachment.name}`, ), - ); - const capacity = remainingComposerAttachmentSlots( - composerImagesRef.current.length + composerFilesRef.current.length + restoredFileCount, - pendingThreadImportsRef.current.get(composerTargetKey(composerDraftTarget)) ?? 0, - ); - const pending = entry.attachments.filter( - (attachment) => - !existingIds.has(attachment.id) && - !existingDedupKeys.has( - `${attachment.mimeType}\0${attachment.sizeBytes}\0${attachment.name}`, - ), - ); - // Anything past the attachment limit cannot be restored. The entry is - // already out of the queue, so report the overflow by name instead of - // discarding it silently. - unrestoredImageNames = pending.slice(capacity).map((attachment) => attachment.name); - const restoredImages = hydrateImagesFromPersisted(pending.slice(0, capacity)); - if (restoredImages.length > 0) { - addComposerDraftImages(composerDraftTarget, restoredImages); - } + ); + // Anything past the attachment limit cannot be restored. The entry is + // already out of the queue, so report the overflow by name instead of + // discarding it silently. + unrestoredImageNames = pending.slice(capacity).map((attachment) => attachment.name); + const restoredImages = hydrateImagesFromPersisted(pending.slice(0, capacity)); + if (restoredImages.length > 0) { + addComposerDraftImages(composerDraftTarget, restoredImages); } + } - // Deliberately no model/provider restore: the stash exists to carry a - // prompt across threads and providers, so whatever the composer has - // selected right now stays selected. - - // Each cause gets its own sentence so "too large" is never blamed for a - // file that actually failed to decode, or for one the composer simply - // had no room to take back. - const missingImageReasons: string[] = []; - if (entry.droppedImageNames.length > 0) { - missingImageReasons.push( - `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`, - ); - } - if (entry.unreadableImageNames && entry.unreadableImageNames.length > 0) { - missingImageReasons.push( - `${entry.unreadableImageNames.join(", ")} could not be read when this prompt was saved.`, - ); - } - if (unrestoredImageNames.length > 0) { - missingImageReasons.push( - `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, - ); - } - if (unrestoredFileNames.length > 0) { - missingImageReasons.push( - `${unrestoredFileNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, - ); - } - if (expiredFileNames.length > 0) { - missingImageReasons.push( - `${expiredFileNames.join(", ")}: stashed files are kept for 24 hours and this upload expired. Attach the file again.`, - ); - } - if (missingImageReasons.length > 0) { - toastManager.add({ - type: "warning", - title: "Some attachments were not restored", - description: missingImageReasons.join(" "), - }); - } + // Deliberately no model/provider restore: the stash exists to carry a + // prompt across threads and providers, so whatever the composer has + // selected right now stays selected. + + // Each cause gets its own sentence so "too large" is never blamed for a + // file that actually failed to decode, or for one the composer simply + // had no room to take back. + const missingImageReasons: string[] = []; + if (entry.droppedImageNames.length > 0) { + missingImageReasons.push( + `${entry.droppedImageNames.join(", ")} exceeded the stash size limit when this prompt was saved.`, + ); + } + if (entry.unreadableImageNames && entry.unreadableImageNames.length > 0) { + missingImageReasons.push( + `${entry.unreadableImageNames.join(", ")} could not be read when this prompt was saved.`, + ); + } + if (unrestoredImageNames.length > 0) { + missingImageReasons.push( + `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, + ); + } + if (unrestoredFileNames.length > 0) { + missingImageReasons.push( + `${unrestoredFileNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, + ); + } + if (expiredFileNames.length > 0) { + missingImageReasons.push( + `${expiredFileNames.join(", ")}: stashed files are kept for 24 hours and this upload expired. Attach the file again.`, + ); + } + if (missingImageReasons.length > 0) { + toastManager.add({ + type: "warning", + title: "Some attachments were not restored", + description: missingImageReasons.join(" "), + }); + } - // Only yank the caret to the end when text was actually inserted; - // restoring images alone should leave the user where they were typing. - if (promptChanged) { - window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAtEnd(); - }); - } - }, - [ - addComposerDraftFiles, - addComposerDraftImages, - composerDraftTarget, - composerFilesRef, - composerImagesRef, - environmentId, - promptRef, - setComposerDraftPrompt, - setComposerTrigger, - takeStashEntry, - importContextRecords, - ], - ); + // Only yank the caret to the end when text was actually inserted; + // restoring images alone should leave the user where they were typing. + if (promptChanged) { + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAtEnd(); + }); + } + }; + const restoreStashEntryStable = useStableCallback(restoreStashEntry); - const deleteStashEntry = useCallback( - (entry: PromptStashEntry) => { - const { entry: removed, durable } = takeStashEntry(entry.id); - if (!stashQueue.some((candidate) => candidate.id !== entry.id)) { - setIsStashMenuOpen(false); - } - if (durable && removed) { - for (const file of removed.files ?? []) { - releasePersistedAttachmentUpload({ - id: file.id, - environmentId: file.environmentId, - attachmentId: file.attachmentId, - }); - } - } - if (!durable) { - toastManager.add({ - type: "warning", - title: "Stash entry may come back", - description: - "Browser storage rejected the delete, so this prompt could reappear after a reload.", - data: { hideCopyButton: true }, + const deleteStashEntry = (entry: PromptStashEntry) => { + const { entry: removed, durable } = takeStashEntry(entry.id); + if (!stashQueue.some((candidate) => candidate.id !== entry.id)) { + setIsStashMenuOpen(false); + } + if (durable && removed) { + for (const file of removed.files ?? []) { + releasePersistedAttachmentUpload({ + id: file.id, + environmentId: file.environmentId, + attachmentId: file.attachmentId, }); } - }, - [stashQueue, takeStashEntry], - ); + } + if (!durable) { + toastManager.add({ + type: "warning", + title: "Stash entry may come back", + description: + "Browser storage rejected the delete, so this prompt could reappear after a reload.", + data: { hideCopyButton: true }, + }); + } + }; + const deleteStashEntryStable = useStableCallback(deleteStashEntry); - const stashCurrentPrompt = useCallback(async () => { + const stashCurrentPrompt = async () => { // Stashing clears the draft. A pasted attachment still downloading would then land in the // emptied composer instead of travelling with the entry it belongs to. if (pendingDraftWork.has(attachmentTargetKeyRef.current)) { @@ -5264,38 +5036,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // snapshot's ⌘S until the composer remounts. stashInFlightRef.current.delete(snapshotKey); } - }, [ - clearComposerDraftPromptAndImages, - clearComposerDraftTerminalContexts, - setComposerDraftPrompt, - setComposerTrigger, - composerDraftTarget, - composerFilesRef, - composerImagesRef, - composerTerminalContextsRef, - composerReviewComments, - composerPreviewAnnotations, - removeComposerDraftReviewComment, - removeComposerDraftPreviewAnnotation, - environmentId, - finalizeStashEntryImages, - promptRef, - pulseStashBadge, - restoreStashEntry, - stashEntryToQueue, - ]); + }; + const stashCurrentPromptStable = useStableCallback(stashCurrentPrompt); - const toggleStashMenu = useCallback(() => { + const toggleStashMenu = () => { if (isComposerCollapsedMobile) { expandMobileComposer(); setIsStashMenuOpen(true); return; } setIsStashMenuOpen((open) => !open); - }, [expandMobileComposer, isComposerCollapsedMobile]); - const toggleTasksDrawer = useCallback(() => { + }; + const toggleStashMenuStable = useStableCallback(toggleStashMenu); + const toggleTasksDrawer = () => { setIsTasksDrawerOpen((open) => !open); - }, []); + }; + const toggleTasksDrawerStable = useStableCallback(toggleTasksDrawer); const hasBannerItems = props.bannerItems.length > 0; const hasBlockingComposerTopDrawer = activePendingApproval !== null || pendingUserInputs.length > 0; @@ -5308,7 +5064,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const inlineTasksBadge = showInlineTasksBadge ? ( @@ -5862,7 +5618,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (isComposerApprovalState || projectSelectionRequired || activePendingProgress !== null) { return; } - void stashCurrentPrompt(); + void stashCurrentPromptStable(); }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); @@ -5873,7 +5629,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) keybindings, pendingUserInputs.length, projectSelectionRequired, - stashCurrentPrompt, + stashCurrentPromptStable, isRevertingCheckpoint, terminalOpen, ]); @@ -6261,94 +6017,81 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) event.stopPropagation(); }; - const insertComposerText = useCallback( - ( - text: string, - position: "cursor" | "end", - options?: { - ensureLeadingBoundary?: boolean; - citationCommentAnchor?: AssistantCitationSourceAnchor; - clipboardData?: DataTransfer; - }, - ): boolean => { - if ( - text.length === 0 || - isConnecting || - isComposerApprovalState || - pendingUserInputs.length > 0 || - projectSelectionRequired || - (options?.citationCommentAnchor && !composerEditorRef.current) - ) { - return false; - } - if (options?.clipboardData) { - text = importPastedComposerText(options.clipboardData, importContextFragment); - } - const prompt = promptRef.current; - const cursor = position === "cursor" ? readComposerSnapshot().expandedCursor : prompt.length; - const needsLeadingSpace = - (options?.ensureLeadingBoundary ?? false) && - cursor > 0 && - !/\s/.test(prompt[cursor - 1] ?? ""); - const rangeEnd = extendReplacementRangeForTrailingSpace(prompt, cursor, text); - return applyPromptReplacement( - cursor, - rangeEnd, - needsLeadingSpace ? ` ${text}` : text, - options?.citationCommentAnchor - ? { - citationComment: { - start: cursor + (needsLeadingSpace ? 1 : 0), - sourceAnchor: options.citationCommentAnchor, - // An existing space after the cursor is consumed and re-emitted, so it stays. - insertedSpaces: { before: needsLeadingSpace, after: rangeEnd === cursor }, - }, - focusEditorAfterReplace: false, - } - : undefined, - ); - }, - [ - applyPromptReplacement, - isComposerApprovalState, - isConnecting, - pendingUserInputs.length, - projectSelectionRequired, - promptRef, - readComposerSnapshot, - importContextFragment, - ], - ); - - const insertComposerTextAtEnd = useCallback( - (text, options) => { - const inserted = insertComposerText(text, "end", options); - if (inserted && isComposerCollapsedMobile) { - // The expanded editor is hidden at phone widths, so its scheduled - // focus cannot expand the composer by itself. Reveal it before the - // focus frame runs to preserve type-to-focus and external inserts. - expandMobileComposer(); - } - return inserted; + const insertComposerText = ( + text: string, + position: "cursor" | "end", + options?: { + ensureLeadingBoundary?: boolean; + citationCommentAnchor?: AssistantCitationSourceAnchor; + clipboardData?: DataTransfer; }, - [expandMobileComposer, insertComposerText, isComposerCollapsedMobile], - ); + ): boolean => { + if ( + text.length === 0 || + isConnecting || + isComposerApprovalState || + pendingUserInputs.length > 0 || + projectSelectionRequired || + (options?.citationCommentAnchor && !composerEditorRef.current) + ) { + return false; + } + if (options?.clipboardData) { + text = importPastedComposerText(options.clipboardData, importContextFragment); + } + const prompt = promptRef.current; + const cursor = position === "cursor" ? readComposerSnapshot().expandedCursor : prompt.length; + const needsLeadingSpace = + (options?.ensureLeadingBoundary ?? false) && + cursor > 0 && + !/\s/.test(prompt[cursor - 1] ?? ""); + const rangeEnd = extendReplacementRangeForTrailingSpace(prompt, cursor, text); + return applyPromptReplacement( + cursor, + rangeEnd, + needsLeadingSpace ? ` ${text}` : text, + options?.citationCommentAnchor + ? { + citationComment: { + start: cursor + (needsLeadingSpace ? 1 : 0), + sourceAnchor: options.citationCommentAnchor, + // An existing space after the cursor is consumed and re-emitted, so it stays. + insertedSpaces: { before: needsLeadingSpace, after: rangeEnd === cursor }, + }, + focusEditorAfterReplace: false, + } + : undefined, + ); + }; + const insertComposerTextStable = useStableCallback(insertComposerText); + + const insertComposerTextAtEnd: ChatComposerHandle["insertTextAtEnd"] = (text, options) => { + const inserted = insertComposerText(text, "end", options); + if (inserted && isComposerCollapsedMobile) { + // The expanded editor is hidden at phone widths, so its scheduled + // focus cannot expand the composer by itself. Reveal it before the + // focus frame runs to preserve type-to-focus and external inserts. + expandMobileComposer(); + } + return inserted; + }; + const insertComposerTextAtEndStable = useStableCallback(insertComposerTextAtEnd); // Context produced by other panels (diff comments, preview picks) asks the store to place // its chip; while this composer is mounted for the draft, that means the caret. - const insertContextReferencesAtCaret = useCallback( - (references: ReadonlyArray): boolean => - insertComposerText(`${references.map(formatInlineContextReference).join(" ")} `, "cursor", { - ensureLeadingBoundary: true, - }), - [insertComposerText], - ); + const insertContextReferencesAtCaret = ( + references: ReadonlyArray, + ): boolean => + insertComposerText(`${references.map(formatInlineContextReference).join(" ")} `, "cursor", { + ensureLeadingBoundary: true, + }); + const insertContextReferencesAtCaretStable = useStableCallback(insertContextReferencesAtCaret); const setContextInsertionHandler = useComposerDraftStore( (store) => store.setContextInsertionHandler, ); useEffect(() => { - return setContextInsertionHandler(composerDraftTarget, insertContextReferencesAtCaret); - }, [composerDraftTarget, insertContextReferencesAtCaret, setContextInsertionHandler]); + return setContextInsertionHandler(composerDraftTarget, insertContextReferencesAtCaretStable); + }, [composerDraftTarget, insertContextReferencesAtCaretStable, setContextInsertionHandler]); // File-tree drags land as mentions. Handled in the capture phase so the // editor never sees the drop; the load-bearing rules (native stop, "move" @@ -6380,7 +6123,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) await importComposerThreadAttachment({ targetKey, pendingImports: pendingThreadImportsRef.current, - countReservedAttachments, + countReservedAttachments: countReservedAttachmentsStable, load: async () => { const result = await loadThreadTranscript({ environmentId: ref.environmentId, @@ -6443,12 +6186,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const text = `${records .map((record) => formatInlineContextReference(threadContextReference(record))) .join(" ")} `; - const snapshot = readComposerSnapshot(); + const snapshot = readComposerSnapshotStable(); const cursor = snapshot.expandedCursor; const leadingSpace = cursor > 0 && !/\s/.test(snapshot.value[cursor - 1] ?? ""); const inserted = canAnswer && - applyPromptReplacement( + applyPromptReplacementStable( cursor, extendReplacementRangeForTrailingSpace(snapshot.value, cursor, text), leadingSpace ? ` ${text}` : text, @@ -6464,7 +6207,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) addComposerDraftThreadContexts(questionAttachmentTarget, records, { appendReference: false, }); - if (isComposerCollapsedMobile) expandMobileComposer(); + if (isComposerCollapsedMobile) expandMobileComposerStable(); } else { addComposerDraftThreadContexts(composerDraftTarget, records); } @@ -6490,15 +6233,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) window.addEventListener("dragend", onWindowDragEnd); return () => window.removeEventListener("dragend", onWindowDragEnd); }, [isDragOverComposer]); - const handleInterruptPrimaryAction = useCallback(() => { + const handleInterruptPrimaryAction = () => { void onInterrupt(); - }, [onInterrupt]); - const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { + }; + const handleInterruptPrimaryActionStable = useStableCallback(handleInterruptPrimaryAction); + const handleImplementPlanInNewThreadPrimaryAction = () => { void onImplementPlanInNewThread(); - }, [onImplementPlanInNewThread]); + }; + const handleImplementPlanInNewThreadPrimaryActionStable = useStableCallback( + handleImplementPlanInNewThreadPrimaryAction, + ); // The phone composer collapses when the editor loses focus. Desktop only // rests on a timeline scroll, so losing focus there changes nothing. - const scheduleComposerCollapseCheck = useCallback(() => { + const scheduleComposerCollapseCheck = () => { if (!isMobileViewport || mobileComposerExpandInFlightRef.current) { return; } @@ -6525,7 +6272,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } setIsComposerFocused(false); }); - }, [isMobileViewport, setIsComposerFocused]); + }; useEffect(() => { return () => { @@ -6544,7 +6291,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Imperative handle // ------------------------------------------------------------------ - const openModelPicker = useCallback(() => { + const openModelPicker = () => { if (composerControlsHidden) { if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); @@ -6554,7 +6301,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerFocused(true); } setIsComposerModelPickerOpen(true); - }, [composerControlsHidden, setIsComposerFocused, setIsComposerScrollCollapsed]); + }; + const openModelPickerStable = useStableCallback(openModelPicker); useImperativeHandle( composerRef, @@ -6610,7 +6358,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); continue; } - insertComposerTextAtEnd(`${serializeComposerFileLink(path)} `, { + insertComposerTextAtEndStable(`${serializeComposerFileLink(path)} `, { ensureLeadingBoundary: true, }); } @@ -6619,7 +6367,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hasPendingAttachments: () => (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0 || pendingDraftWork.has(attachmentTargetKey), - insertTextAtEnd: insertComposerTextAtEnd, + insertTextAtEnd: insertComposerTextAtEndStable, pasteTextAtEnd: (text: string, options) => { const bypassAutoAttachment = options?.bypassAutoAttachment === true || @@ -6638,17 +6386,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; }, citeAssistantText: (citation, sourceAnchor) => - insertComposerText( + insertComposerTextStable( formatAssistantCitationForComposer(citation, citation.comment), "cursor", { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor }, ), - openModelPicker, + openModelPicker: openModelPickerStable, toggleModelPicker: () => { if (isComposerModelPickerOpen) { setIsComposerModelPickerOpen(false); } else { - openModelPicker(); + openModelPickerStable(); } }, openControl: (command) => { @@ -6673,14 +6421,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) trigger.focus({ preventScroll: true }); trigger.click(); }, - compactContext: compactThreadContext, + compactContext: compactThreadContextStable, isModelPickerOpen: () => isComposerModelPickerOpen, isCaretAtStart: () => { const range = composerEditorRef.current?.readSelectionRange(); return range !== undefined && range.start === 0 && range.end === 0; }, readSnapshot: () => { - return readComposerSnapshot(); + return readComposerSnapshotStable(); }, resetCursorState: (options?: { cursor?: number; @@ -6706,7 +6454,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, addTerminalContext: (selection: TerminalContextSelection) => { if (!activeThread || isChoiceOnlyPendingQuestion || isLiteralPendingAnswer) return; - const snapshot = readComposerSnapshot(); + const snapshot = readComposerSnapshotStable(); const context = { id: randomUUID(), threadId: activeThread.id, @@ -6787,8 +6535,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress, composerTerminalContexts, insertComposerDraftTerminalContext, - insertComposerText, - insertComposerTextAtEnd, + insertComposerTextStable, + insertComposerTextAtEndStable, promptRef, composerImagesRef, composerFilesRef, @@ -6804,10 +6552,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isLiteralPendingAnswer, pendingUserInputs.length, projectSelectionRequired, - applyPromptReplacement, + applyPromptReplacementStable, isComposerModelPickerOpen, - openModelPicker, - readComposerSnapshot, + openModelPickerStable, + readComposerSnapshotStable, resetComposerTrigger, setComposerTrigger, selectedModel, @@ -6823,7 +6571,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderModels, interactionMode, planModeUiEnabled, - compactThreadContext, + compactThreadContextStable, restoreAfterTimelineReachedEnd, getTimelineScrollableNode, isTimelineAtLogicalEnd, @@ -6836,7 +6584,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return (
{ const target = event.target; if (isInsideRestingComposerControlScope(target)) return; @@ -7039,7 +6787,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2", )} onPointerDown={(event) => event.preventDefault()} - onClick={expandMobileComposer} + onClick={expandMobileComposerStable} aria-label="Write custom answer" > {activePendingProgress?.customAnswer || "Write custom answer"} @@ -7066,9 +6814,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hasSendableContent={false} preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} - onInterrupt={handleInterruptPrimaryAction} + onInterrupt={handleInterruptPrimaryActionStable} onImplementPlanInNewThread={ - handleImplementPlanInNewThreadPrimaryAction + handleImplementPlanInNewThreadPrimaryActionStable } /> ) : null} @@ -7086,7 +6834,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeTasksProgress && activeTaskSteps ? ( @@ -7095,7 +6843,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) @@ -7108,7 +6856,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) menuOpen={isStashMenuOpen} pulseKey={stashPulse.key} pulsing={stashPulse.active} - onToggleMenu={toggleStashMenu} + onToggleMenu={toggleStashMenuStable} /> ) : null} @@ -7145,7 +6893,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : "text-placeholder", )} onPointerDown={(event) => event.preventDefault()} - onClick={isChoiceOnlyPendingQuestion ? undefined : expandMobileComposer} + onClick={isChoiceOnlyPendingQuestion ? undefined : expandMobileComposerStable} disabled={isChoiceOnlyPendingQuestion} aria-label="Expand composer" > @@ -7215,8 +6963,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelPickerOpen: false, }, })} - onRestore={restoreStashEntry} - onDelete={deleteStashEntry} + onRestore={restoreStashEntryStable} + onDelete={deleteStashEntryStable} onClose={() => setIsStashMenuOpen(false)} /> @@ -7241,8 +6989,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) triggerKind={composerTriggerKind} emptyStateText={composerMenuEmptyState} activeItemId={activeComposerMenuItem?.id ?? null} - onHighlightedItemChange={onComposerMenuItemHighlighted} - onSelect={onSelectComposerItem} + onHighlightedItemChange={onComposerMenuItemHighlightedStable} + onSelect={onSelectComposerItemStable} /> )} @@ -7682,8 +7430,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } cursor={composerCursor} contextRecords={composerContextRecords} - buildContextClipboardFragment={buildContextClipboardFragment} - importContextFragment={importContextFragment} + buildContextClipboardFragment={buildContextClipboardFragmentStable} + importContextFragment={importContextFragmentStable} skills={selectedProviderSkills} containerClassName={cn(isComposerResting && "min-w-0 flex-1")} className={cn( @@ -7696,14 +7444,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerResting && "flex items-center overflow-hidden whitespace-nowrap leading-8", )} - onChange={onPromptChange} - onVisibleSelectionChange={expandComposerForEditorChange} + onChange={onPromptChangeStable} + onVisibleSelectionChange={expandComposerForEditorChangeStable} onCommandKeyDown={onComposerCommandKey} onPageScrollKeyDown={onPageScrollKeyDown} onPageScrollKeyUp={onPageScrollKeyUp} onPageScrollRelease={onPageScrollRelease} - onCitationSubmitAndSend={submitCitationAndSend} - onCitationDraftSave={saveCitationDraftToPrompt} + onCitationSubmitAndSend={submitCitationAndSendStable} + onCitationDraftSave={saveCitationDraftToPromptStable} onPaste={onComposerPaste} placeholder={ isComposerApprovalState @@ -7757,8 +7505,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hasSendableContent={false} preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} - onInterrupt={handleInterruptPrimaryAction} - onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onInterrupt={handleInterruptPrimaryActionStable} + onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryActionStable} /> ) : null} @@ -7884,11 +7632,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) canResume={showResumeAction} preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting} isEditingQueuedMessage={isEditingQueuedMessage} - onSubmitMessage={handleSubmitMessage} + onSubmitMessage={handleSubmitMessageStable} onResume={onResume} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} - onInterrupt={handleInterruptPrimaryAction} - onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onInterrupt={handleInterruptPrimaryActionStable} + onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryActionStable} compactBeforeSendTokens={props.resumeCompactionTokens} keepFullHistory={props.keepFullHistory} onToggleKeepFullHistory={props.onToggleKeepFullHistory} @@ -7896,7 +7644,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) compactDisabled || noProviderAvailable || isSendBusy || isConnecting } compactDisabledReason={resolvedCompactDisabledReason} - {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})} + {...(compactCommandAvailable + ? { onCompactContext: compactThreadContextStable } + : {})} /> diff --git a/docs/internals/web-ui.md b/docs/internals/web-ui.md index 8f8213cc7dc3..8eba6c044959 100644 --- a/docs/internals/web-ui.md +++ b/docs/internals/web-ui.md @@ -37,3 +37,19 @@ The composer banners are the reference. Build new UI the same way. Compose or extend a component instead of copying its markup and classes. When a call site repeats a treatment another one already uses, make it a variant or a slot on the shared component. A new composer banner or notice uses the `ComposerBanner` slots. + +## Handlers in large components + +V8 gives every closure created in one render a single shared scope. A memoized handler keeps +the scope of the render that created it, and that scope holds the other memoized values of that +render. When handlers in one component change at different times, each keeps an older scope that +holds older handlers, and old renders, with their server config and thread data, stay in memory +for as long as the component is mounted. React Compiler's memoized closures do the same. + +In large, long-lived components such as `ChatView` and `ChatComposer`, write handlers as plain +functions and wrap them with [`useStableCallback`](../../apps/web/src/hooks/useStableCallback.ts) +only where they leave the component: props, hook dependency arrays, and effect or memo bodies. +Calls inside the component keep the plain function, so work that resumes after an `await` still +acts on the render it started in. A function that a child calls during render, or after an +`await`, comes from a module-level factory memoized on its inputs, so its closure keeps only +those inputs.