diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 73178a2dcace15c314baa56f3558f03750c2937e..67ad1bbbba908be07fbaefea4fe7fc2f0fc981f6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -474,7 +474,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const draftThread = useComposerDraftStore((store) => store.draftThreadsByThreadId[threadId] ?? null); + const draftThread = useComposerDraftStore( + (store) => store.draftThreadsByThreadId[threadId] ?? null, + ); const promptRef = useRef(prompt); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); @@ -604,7 +606,9 @@ export default function ChatView({ threadId }: ChatViewProps) { markThreadVisited, ]); - const baseThreadModel = resolveModelSlug(activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL); + const baseThreadModel = resolveModelSlug( + activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, + ); const selectedModel = resolveModelSlug(composerDraft.model ?? baseThreadModel); const selectedEffort = composerDraft.effort ?? DEFAULT_REASONING; const modelOptions = getAppModelOptions(settings.customCodexModels, selectedModel); @@ -614,8 +618,14 @@ export default function ChatView({ threadId }: ChatViewProps) { const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const threadActivities = activeThread?.activities ?? []; - const workLogEntries = deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined); - const latestTurnHasToolActivity = hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId); + const workLogEntries = deriveWorkLogEntries( + threadActivities, + activeLatestTurn?.turnId ?? undefined, + ); + const latestTurnHasToolActivity = hasToolActivityForTurn( + threadActivities, + activeLatestTurn?.turnId, + ); const pendingApprovals = derivePendingApprovals(threadActivities); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; @@ -680,13 +690,22 @@ export default function ChatView({ threadId }: ChatViewProps) { delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); }, []); - const timelineMessages = (() => { - const serverMessages = activeThread?.messages ?? []; + const serverMessages = activeThread?.messages; + const timelineMessages = useMemo(() => { + const messages = serverMessages ?? []; const serverMessagesWithPreviewHandoff = Object.keys(attachmentPreviewHandoffByMessageId).length === 0 - ? serverMessages - : serverMessages.map((message) => { - if (message.role !== "user" || !message.attachments || message.attachments.length === 0) { + ? messages + : // Spread only fires for the few messages that actually changed; + // unchanged ones early-return their original reference. + // In-place mutation would break React's immutable state contract. + // oxlint-disable-next-line no-map-spread + messages.map((message) => { + if ( + message.role !== "user" || + !message.attachments || + message.attachments.length === 0 + ) { return message; } const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id]; @@ -724,7 +743,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - })(); + }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); const timelineEntries = deriveTimelineEntries(timelineMessages, workLogEntries); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); @@ -937,8 +956,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); - const hasReachedTerminalLimit = - terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT; + const hasReachedTerminalLimit = terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; @@ -1390,12 +1408,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return () => { window.clearTimeout(timer); }; - }, [ - activeThread?.id, - activeThread?.messages, - handoffAttachmentPreviews, - optimisticUserMessages, - ]); + }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); useEffect(() => { promptRef.current = prompt; @@ -1810,7 +1823,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const shouldCreateWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThread.branch) { - setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); + setStoreThreadError( + threadIdForSend, + "Select a base branch before sending in New worktree mode.", + ); return; } @@ -1965,8 +1981,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setSendPhase("sending-turn"); const turnAttachments = await turnAttachmentsPromise; const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request"; - const sandboxMode = - runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; + const sandboxMode = runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), @@ -2169,11 +2184,14 @@ export default function ChatView({ threadId }: ChatViewProps) { workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching); - const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { - promptRef.current = nextPrompt; - setPrompt(nextPrompt); - setComposerCursor(nextCursor); - }, [setComposerCursor, setPrompt]); + const onPromptChange = useCallback( + (nextPrompt: string, nextCursor: number) => { + promptRef.current = nextPrompt; + setPrompt(nextPrompt); + setComposerCursor(nextCursor); + }, + [setComposerCursor, setPrompt], + ); const onKeyDown = (e: React.KeyboardEvent) => { if (composerMenuOpen && composerMenuItems.length > 0) { @@ -2402,8 +2420,12 @@ export default function ChatView({ threadId }: ChatViewProps) { } /> - - Draft attachment could not be saved locally and may be lost on navigation. + + Draft attachment could not be saved locally and may be lost on + navigation. )} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index c2653a90dada45fa073f80f1d60d4bb066a7517a..cce2ed1338243d2a40511e6d6f6b75ef369a9ba7 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -389,6 +389,9 @@ function TerminalViewport({ fitAddonRef.current = null; terminal.dispose(); }; + // autoFocus is intentionally omitted; + // it is only read at mount time and must not trigger terminal teardown/recreation. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, runtimeEnv, terminalId, threadId]); useEffect(() => { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 36f8abac80555a5eca3ee851985aa583e2a462c1..4616543d552bc50b8c0f90b41226dd4df0ccc1f4 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -126,6 +126,9 @@ function errorDetails(error: unknown): string { function EventRouter() { const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setProjectExpanded = useStore((store) => store.setProjectExpanded); + const removeOrphanedTerminalStates = useTerminalStateStore( + (store) => store.removeOrphanedTerminalStates, + ); const queryClient = useQueryClient(); const navigate = useNavigate(); const pathname = useRouterState({ select: (state) => state.location.pathname }); @@ -143,9 +146,6 @@ function EventRouter() { let syncing = false; let pending = false; - const removeOrphanedTerminalStates = - useTerminalStateStore.getState().removeOrphanedTerminalStates; - const flushSnapshotSync = async (): Promise => { const snapshot = await api.orchestration.getSnapshot(); if (disposed) return; @@ -276,7 +276,7 @@ function EventRouter() { unsubWelcome(); unsubServerConfigUpdated(); }; - }, [navigate, queryClient, setProjectExpanded, syncServerReadModel]); + }, [navigate, queryClient, removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel]); return null; }