From 88f950d5894319de5fce7e5b0a9450842195c467 Mon Sep 17 00:00:00 2001 From: Christian Smith Date: Thu, 12 Mar 2026 08:30:18 -0500 Subject: [PATCH] feat: extract auto scroll related hooks from ChatView and structural chat composer --- apps/web/src/components/ChatView.tsx | 965 ++---------------- apps/web/src/components/chat/ChatComposer.tsx | 850 +++++++++++++++ .../hooks/chat/useMessageListAutoScroll.tsx | 217 ++++ 3 files changed, 1178 insertions(+), 854 deletions(-) create mode 100644 apps/web/src/components/chat/ChatComposer.tsx create mode 100644 apps/web/src/hooks/chat/useMessageListAutoScroll.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4fb0bfb86..2dd791fcc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9,8 +9,6 @@ import { type ProjectEntry, type ProjectScript, type ModelSlug, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ProviderApprovalDecision, type ServerProviderStatus, @@ -28,7 +26,7 @@ import { normalizeModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; @@ -57,7 +55,6 @@ import { isLatestTurnSettled, formatElapsed, } from "../session-logic"; -import { isScrollContainerNearBottom } from "../chat-scroll"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -68,7 +65,6 @@ import { useStore } from "../store"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, - proposedPlanTitle, resolvePlanFollowUpSubmission, } from "../proposedPlan"; import { truncateTitle } from "../truncateTitle"; @@ -87,22 +83,9 @@ import BranchToolbar from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { - BotIcon, - ChevronDownIcon, - ChevronLeftIcon, - ChevronRightIcon, - CircleAlertIcon, - ListTodoIcon, - LockIcon, - LockOpenIcon, - XIcon, -} from "lucide-react"; +import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "lucide-react"; import { Button } from "./ui/button"; -import { Separator } from "./ui/separator"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { cn, randomUUID } from "~/lib/utils"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -124,21 +107,14 @@ import { useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; -import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; -import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; -import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; -import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; -import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; -import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; -import { CodexTraitsPicker } from "./chat/CodexTraitsPicker"; -import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; -import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; -import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; -import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; +import { ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { AVAILABLE_PROVIDER_OPTIONS } from "./chat/ProviderModelPicker"; +import { ComposerCommandItem } from "./chat/ComposerCommandMenu"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { @@ -156,9 +132,10 @@ import { SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useMessageListAutoScroll } from "../hooks/chat/useMessageListAutoScroll"; +import { ChatComposer } from "./chat/ChatComposer"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; -const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; @@ -204,9 +181,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); const setComposerDraftCodexFastMode = useComposerDraftStore((store) => store.setCodexFastMode); - const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); - const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); @@ -227,7 +202,6 @@ export default function ChatView({ threadId }: ChatViewProps) { (store) => store.draftThreadsByThreadId[threadId] ?? null, ); const promptRef = useRef(prompt); - const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); @@ -250,7 +224,6 @@ export default function ChatView({ threadId }: ChatViewProps) { useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); - const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. @@ -273,22 +246,8 @@ export default function ChatView({ threadId }: ChatViewProps) { {}, LastInvokedScriptByProjectSchema, ); - const messagesScrollRef = useRef(null); - const [messagesScrollElement, setMessagesScrollElement] = useState(null); - const shouldAutoScrollRef = useRef(true); - const lastKnownScrollTopRef = useRef(0); - const isPointerScrollActiveRef = useRef(false); - const lastTouchClientYRef = useRef(null); - const pendingUserScrollUpIntentRef = useRef(false); - const pendingAutoScrollFrameRef = useRef(null); - const pendingInteractionAnchorRef = useRef<{ - element: HTMLElement; - top: number; - } | null>(null); - const pendingInteractionAnchorFrameRef = useRef(null); + const composerEditorRef = useRef(null); - const composerFormRef = useRef(null); - const composerFormHeightRef = useRef(0); const composerImagesRef = useRef([]); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); @@ -297,12 +256,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); - const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); - const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { - messagesScrollRef.current = element; - setMessagesScrollElement(element); - }, []); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), @@ -320,24 +274,12 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [setComposerDraftPrompt, threadId], ); - const addComposerImage = useCallback( - (image: ComposerImageAttachment) => { - addComposerDraftImage(threadId, image); - }, - [addComposerDraftImage, threadId], - ); const addComposerImagesToDraft = useCallback( (images: ComposerImageAttachment[]) => { addComposerDraftImages(threadId, images); }, [addComposerDraftImages, threadId], ); - const removeComposerImageFromDraft = useCallback( - (imageId: string) => { - removeComposerDraftImage(threadId, imageId); - }, - [removeComposerDraftImage, threadId], - ); const serverThread = threads.find((t) => t.id === threadId); const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); @@ -1444,198 +1386,20 @@ export default function ChatView({ threadId }: ChatViewProps) { // Auto-scroll on new messages const messageCount = timelineMessages.length; - const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); - lastKnownScrollTopRef.current = scrollContainer.scrollTop; - shouldAutoScrollRef.current = true; - }, []); - const cancelPendingStickToBottom = useCallback(() => { - const pendingFrame = pendingAutoScrollFrameRef.current; - if (pendingFrame === null) return; - pendingAutoScrollFrameRef.current = null; - window.cancelAnimationFrame(pendingFrame); - }, []); - const cancelPendingInteractionAnchorAdjustment = useCallback(() => { - const pendingFrame = pendingInteractionAnchorFrameRef.current; - if (pendingFrame === null) return; - pendingInteractionAnchorFrameRef.current = null; - window.cancelAnimationFrame(pendingFrame); - }, []); - const scheduleStickToBottom = useCallback(() => { - if (pendingAutoScrollFrameRef.current !== null) return; - pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { - pendingAutoScrollFrameRef.current = null; - scrollMessagesToBottom(); - }); - }, [scrollMessagesToBottom]); - const onMessagesClickCapture = useCallback( - (event: React.MouseEvent) => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer || !(event.target instanceof Element)) return; - - const trigger = event.target.closest( - "button, summary, [role='button'], [data-scroll-anchor-target]", - ); - if (!trigger || !scrollContainer.contains(trigger)) return; - if (trigger.closest("[data-scroll-anchor-ignore]")) return; - pendingInteractionAnchorRef.current = { - element: trigger, - top: trigger.getBoundingClientRect().top, - }; - - cancelPendingInteractionAnchorAdjustment(); - pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => { - pendingInteractionAnchorFrameRef.current = null; - const anchor = pendingInteractionAnchorRef.current; - pendingInteractionAnchorRef.current = null; - const activeScrollContainer = messagesScrollRef.current; - if (!anchor || !activeScrollContainer) return; - if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return; - - const nextTop = anchor.element.getBoundingClientRect().top; - const delta = nextTop - anchor.top; - if (Math.abs(delta) < 0.5) return; - - activeScrollContainer.scrollTop += delta; - lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; - }); - }, - [cancelPendingInteractionAnchorAdjustment], - ); - const forceStickToBottom = useCallback(() => { - cancelPendingStickToBottom(); - scrollMessagesToBottom(); - scheduleStickToBottom(); - }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); - const onMessagesScroll = useCallback(() => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - const currentScrollTop = scrollContainer.scrollTop; - const isNearBottom = isScrollContainerNearBottom(scrollContainer); - - if (!shouldAutoScrollRef.current && isNearBottom) { - shouldAutoScrollRef.current = true; - pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } - pendingUserScrollUpIntentRef.current = false; - } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } - } else if (shouldAutoScrollRef.current && !isNearBottom) { - // Catch-all for keyboard/assistive scroll interactions. - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; - if (scrolledUp) { - shouldAutoScrollRef.current = false; - } - } - - lastKnownScrollTopRef.current = currentScrollTop; - }, []); - const onMessagesWheel = useCallback((event: React.WheelEvent) => { - if (event.deltaY < 0) { - pendingUserScrollUpIntentRef.current = true; - } - }, []); - const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = true; - }, []); - const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { - isPointerScrollActiveRef.current = false; - }, []); - const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - if (!touch) return; - const previousTouchY = lastTouchClientYRef.current; - if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { - pendingUserScrollUpIntentRef.current = true; - } - lastTouchClientYRef.current = touch.clientY; - }, []); - const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { - lastTouchClientYRef.current = null; - }, []); - useEffect(() => { - return () => { - cancelPendingStickToBottom(); - cancelPendingInteractionAnchorAdjustment(); - }; - }, [cancelPendingInteractionAnchorAdjustment, cancelPendingStickToBottom]); - useLayoutEffect(() => { - if (!activeThread?.id) return; - shouldAutoScrollRef.current = true; - scheduleStickToBottom(); - const timeout = window.setTimeout(() => { - const scrollContainer = messagesScrollRef.current; - if (!scrollContainer) return; - if (isScrollContainerNearBottom(scrollContainer)) return; - scheduleStickToBottom(); - }, 96); - return () => { - window.clearTimeout(timeout); - }; - }, [activeThread?.id, scheduleStickToBottom]); - useLayoutEffect(() => { - const composerForm = composerFormRef.current; - if (!composerForm) return; - const measureComposerFormWidth = () => composerForm.clientWidth; - - composerFormHeightRef.current = composerForm.getBoundingClientRect().height; - setIsComposerFooterCompact( - shouldUseCompactComposerFooter(measureComposerFormWidth(), { - hasWideActions: composerFooterHasWideActions, - }), - ); - if (typeof ResizeObserver === "undefined") return; - - const observer = new ResizeObserver((entries) => { - const [entry] = entries; - if (!entry) return; - - const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { - hasWideActions: composerFooterHasWideActions, - }); - setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); - - const nextHeight = entry.contentRect.height; - const previousHeight = composerFormHeightRef.current; - composerFormHeightRef.current = nextHeight; - - if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }); - - observer.observe(composerForm); - return () => { - observer.disconnect(); - }; - }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]); - useEffect(() => { - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }, [messageCount, scheduleStickToBottom]); - useEffect(() => { - if (phase !== "running") return; - if (!shouldAutoScrollRef.current) return; - scheduleStickToBottom(); - }, [phase, scheduleStickToBottom, timelineEntries]); + const { + handlers, + forceStickToBottom, + scheduleStickToBottom, + shouldAutoScrollRef, + scrollContainerRef, + scrollContainerElement, + } = useMessageListAutoScroll({ + activeThreadId, + messageCount, + phase, + timelineEntries, + }); useEffect(() => { setExpandedWorkGroups({}); @@ -1724,8 +1488,6 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerHighlightedItemId(null); setComposerCursor(promptRef.current.length); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); - dragDepthRef.current = 0; - setIsDragOverComposer(false); setExpandedImage(null); }, [threadId]); @@ -2000,109 +1762,6 @@ export default function ChatView({ threadId }: ChatViewProps) { toggleTerminalVisibility, ]); - const addComposerImages = (files: File[]) => { - if (!activeThreadId || files.length === 0) return; - - const nextImages: ComposerImageAttachment[] = []; - let nextImageCount = composerImagesRef.current.length; - let error: string | null = null; - for (const file of files) { - if (!file.type.startsWith("image/")) { - error = `Unsupported file type for '${file.name}'. Please attach image files only.`; - continue; - } - if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; - continue; - } - if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; - break; - } - - const previewUrl = URL.createObjectURL(file); - nextImages.push({ - type: "image", - id: randomUUID(), - name: file.name || "image", - mimeType: file.type, - sizeBytes: file.size, - previewUrl, - file, - }); - nextImageCount += 1; - } - - if (nextImages.length === 1 && nextImages[0]) { - addComposerImage(nextImages[0]); - } else if (nextImages.length > 1) { - addComposerImagesToDraft(nextImages); - } - setThreadError(activeThreadId, error); - }; - - const removeComposerImage = (imageId: string) => { - removeComposerImageFromDraft(imageId); - }; - - const onComposerPaste = (event: React.ClipboardEvent) => { - const files = Array.from(event.clipboardData.files); - if (files.length === 0) { - return; - } - const imageFiles = files.filter((file) => file.type.startsWith("image/")); - if (imageFiles.length === 0) { - return; - } - event.preventDefault(); - addComposerImages(imageFiles); - }; - - const onComposerDragEnter = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) { - return; - } - event.preventDefault(); - dragDepthRef.current += 1; - setIsDragOverComposer(true); - }; - - const onComposerDragOver = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) { - return; - } - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setIsDragOverComposer(true); - }; - - const onComposerDragLeave = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) { - return; - } - event.preventDefault(); - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { - return; - } - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) { - setIsDragOverComposer(false); - } - }; - - const onComposerDrop = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) { - return; - } - event.preventDefault(); - dragDepthRef.current = 0; - setIsDragOverComposer(false); - const files = Array.from(event.dataTransfer.files); - addComposerImages(files); - focusComposer(); - }; - const onRevertToTurnCount = useCallback( async (turnCount: number) => { const api = readNativeApi(); @@ -3185,18 +2844,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
{/* Messages */}
{/* Input bar */} -
-
-
- {activePendingApproval ? ( -
- -
- ) : pendingUserInputs.length > 0 ? ( -
- -
- ) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
- -
- ) : null} - - {/* Textarea area */} -
- {composerMenuOpen && !isComposerApprovalState && ( -
- -
- )} - - {!isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.length > 0 && ( -
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} -
- )} - -
- - {/* Bottom toolbar */} - {activePendingApproval ? ( -
- -
- ) : ( -
-
- {/* Provider/model picker */} - - - {isComposerFooterCompact ? ( - - ) : ( - <> - {selectedProvider === "codex" && selectedEffort != null ? ( - <> - - - - ) : null} - - - - - - - - - - {activePlan || activeProposedPlan || planSidebarOpen ? ( - <> - - - - ) : null} - - )} -
- - {/* Right side: send / stop button */} -
- {isPreparingWorktree ? ( - - Preparing worktree... - - ) : null} - {activePendingProgress ? ( -
- {activePendingProgress.questionIndex > 0 ? ( - - ) : null} - -
- ) : phase === "running" ? ( - - ) : pendingUserInputs.length === 0 ? ( - showPlanFollowUpPrompt ? ( - prompt.trim().length > 0 ? ( - - ) : ( -
- - - - } - > - - - - void onImplementPlanInNewThread()} - > - Implement in new thread - - - -
- ) - ) : ( - - ) - ) : null} -
-
- )} -
-
-
+ { + void onInterrupt(); + }} + onSend={onSend} + onImplementPlanInNewThread={() => { + void onImplementPlanInNewThread(); + }} + toggleInteractionMode={toggleInteractionMode} + toggleRuntimeMode={toggleRuntimeMode} + togglePlanSidebar={togglePlanSidebar} + handleRuntimeModeChange={handleRuntimeModeChange} + focusComposer={focusComposer} + setThreadError={setThreadError} + addComposerImagesToDraft={addComposerImagesToDraft} + flags={{ + activePendingIsResponding, + showPlanFollowUpPrompt, + isComposerApprovalState, + hasComposerHeader, + composerMenuOpen, + isComposerMenuLoading, + isSendBusy, + isConnecting, + isPreparingWorktree, + planSidebarOpen, + selectedCodexFastModeEnabled, + composerFooterHasWideActions, + isGitRepo, + }} + /> {isGitRepo && ( ; + composerImagesRef: React.RefObject; + shouldAutoScrollRef: React.RefObject; + scheduleStickToBottom: () => void; + + // Core composer state + prompt: string; + composerCursor: number; + composerImages: ComposerImageAttachment[]; + nonPersistedComposerImageIdSet: Set; + setExpandedImage: React.Dispatch>; + phase: SessionPhase; + + // Pending workflow state + pendingApprovals: PendingApproval[]; + activePendingApproval: PendingApproval | null; + pendingUserInputs: PendingUserInput[]; + activePendingDraftAnswers: Record; + activePendingQuestionIndex: number; + activePendingProgress: PendingUserInputProgress | null; + activePendingResolvedAnswers: Record | null; + activeProposedPlan: LatestProposedPlanState | null; + activePlan: ActivePlanState | null; + respondingUserInputRequestIds: ApprovalRequestId[]; + respondingRequestIds: ApprovalRequestId[]; + + // Composer menu state + composerMenuItems: ComposerCommandItem[]; + composerTriggerKind: ComposerTriggerKind | null; + activeComposerMenuItem: ComposerCommandItem | null; + + // Provider / runtime state + selectedProvider: ProviderKind; + selectedModelForPickerWithCustomFallback: ModelSlug; + lockedProvider: ProviderKind | null; + modelOptionsByProvider: Record>; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + selectedEffort: CodexReasoningEffort; + reasoningOptions: ReadonlyArray; + + // Event handlers + onPromptChange: ( + nextPrompt: string, + nextCursor: number, + cursorAdjacentToMention: boolean, + ) => void; + onComposerCommandKey: ( + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + event: KeyboardEvent, + ) => boolean; + onComposerMenuItemHighlighted: (itemId: string | null) => void; + onSelectComposerItem: (item: ComposerCommandItem) => void; + onProviderModelSelect: (provider: ProviderKind, model: ModelSlug) => void; + onEffortSelect: (effort: CodexReasoningEffort) => void; + onCodexFastModeChange: (enabled: boolean) => void; + onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; + onAdvanceActivePendingUserInput: () => void; + onPreviousActivePendingUserInputQuestion: () => void; + onRespondToApproval: ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => Promise; + onInterrupt: () => void; + onSend: () => void; + onImplementPlanInNewThread: () => void; + toggleInteractionMode: () => void; + toggleRuntimeMode: () => void; + togglePlanSidebar: () => void; + handleRuntimeModeChange: (mode: RuntimeMode) => void; + focusComposer: () => void; + setThreadError: (targetThreadId: ThreadId | null, error: string | null) => void; + addComposerImagesToDraft: (images: ComposerImageAttachment[]) => void; + + // UI flags + flags: { + activePendingIsResponding: boolean; + showPlanFollowUpPrompt: boolean; + isComposerApprovalState: boolean; + hasComposerHeader: boolean; + composerMenuOpen: boolean; + isComposerMenuLoading: boolean; + isSendBusy: boolean; + isConnecting: boolean; + isPreparingWorktree: boolean; + planSidebarOpen: boolean; + selectedCodexFastModeEnabled: boolean; + composerFooterHasWideActions: boolean; + isGitRepo: boolean; + }; +} + +export function ChatComposer({ + threadId, + activeThreadId, + resolvedTheme, + composerEditorRef, + composerImagesRef, + shouldAutoScrollRef, + scheduleStickToBottom, + prompt, + composerCursor, + composerImages, + nonPersistedComposerImageIdSet, + setExpandedImage, + phase, + pendingApprovals, + activePendingApproval, + pendingUserInputs, + activePendingDraftAnswers, + activePendingQuestionIndex, + activePendingProgress, + activePendingResolvedAnswers, + activeProposedPlan, + activePlan, + respondingUserInputRequestIds, + respondingRequestIds, + composerMenuItems, + composerTriggerKind, + activeComposerMenuItem, + selectedProvider, + selectedModelForPickerWithCustomFallback, + lockedProvider, + modelOptionsByProvider, + runtimeMode, + interactionMode, + selectedEffort, + reasoningOptions, + onPromptChange, + onComposerCommandKey, + onComposerMenuItemHighlighted, + onSelectComposerItem, + onProviderModelSelect, + onEffortSelect, + onCodexFastModeChange, + onSelectActivePendingUserInputOption, + onAdvanceActivePendingUserInput, + onPreviousActivePendingUserInputQuestion, + onRespondToApproval, + onInterrupt, + onSend, + onImplementPlanInNewThread, + toggleInteractionMode, + toggleRuntimeMode, + togglePlanSidebar, + handleRuntimeModeChange, + focusComposer, + setThreadError, + addComposerImagesToDraft, + flags: { + activePendingIsResponding, + showPlanFollowUpPrompt, + isComposerApprovalState, + hasComposerHeader, + composerMenuOpen, + isComposerMenuLoading, + isSendBusy, + isConnecting, + isPreparingWorktree, + planSidebarOpen, + selectedCodexFastModeEnabled, + composerFooterHasWideActions, + isGitRepo, + }, +}: ChatComposerProps) { + const composerFormRef = useRef(null); + const composerFormHeightRef = useRef(0); + const dragDepthRef = useRef(0); + + const [isDragOverComposer, setIsDragOverComposer] = useState(false); + const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); + + useEffect(() => { + dragDepthRef.current = 0; + setIsDragOverComposer(false); + }, [threadId]); + + useLayoutEffect(() => { + const composerForm = composerFormRef.current; + if (!composerForm) return; + const measureComposerFormWidth = () => composerForm.clientWidth; + + composerFormHeightRef.current = composerForm.getBoundingClientRect().height; + setIsComposerFooterCompact( + shouldUseCompactComposerFooter(measureComposerFormWidth(), { + hasWideActions: composerFooterHasWideActions, + }), + ); + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver((entries) => { + const [entry] = entries; + if (!entry) return; + + const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { + hasWideActions: composerFooterHasWideActions, + }); + setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); + + const nextHeight = entry.contentRect.height; + const previousHeight = composerFormHeightRef.current; + composerFormHeightRef.current = nextHeight; + + if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; + if (!shouldAutoScrollRef.current) return; + scheduleStickToBottom(); + }); + + observer.observe(composerForm); + return () => { + observer.disconnect(); + }; + }, [activeThreadId, composerFooterHasWideActions, scheduleStickToBottom]); + + const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); + const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); + + const addComposerImage = useCallback( + (image: ComposerImageAttachment) => { + addComposerDraftImage(threadId, image); + }, + [addComposerDraftImage, threadId], + ); + + const removeComposerImageFromDraft = useCallback( + (imageId: string) => { + removeComposerDraftImage(threadId, imageId); + }, + [removeComposerDraftImage, threadId], + ); + + const addComposerImages = (files: File[]) => { + if (!activeThreadId || files.length === 0) return; + + const nextImages: ComposerImageAttachment[] = []; + let nextImageCount = composerImagesRef.current.length; + let error: string | null = null; + for (const file of files) { + if (!file.type.startsWith("image/")) { + error = `Unsupported file type for '${file.name}'. Please attach image files only.`; + continue; + } + if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; + continue; + } + if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; + break; + } + + const previewUrl = URL.createObjectURL(file); + nextImages.push({ + type: "image", + id: randomUUID(), + name: file.name || "image", + mimeType: file.type, + sizeBytes: file.size, + previewUrl, + file, + }); + nextImageCount += 1; + } + + if (nextImages.length === 1 && nextImages[0]) { + addComposerImage(nextImages[0]); + } else if (nextImages.length > 1) { + addComposerImagesToDraft(nextImages); + } + setThreadError(activeThreadId, error); + }; + + const removeComposerImage = (imageId: string) => { + removeComposerImageFromDraft(imageId); + }; + + const onComposerPaste = (event: React.ClipboardEvent) => { + const files = Array.from(event.clipboardData.files); + if (files.length === 0) { + return; + } + const imageFiles = files.filter((file) => file.type.startsWith("image/")); + if (imageFiles.length === 0) { + return; + } + event.preventDefault(); + addComposerImages(imageFiles); + }; + + const onComposerDragEnter = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) { + return; + } + event.preventDefault(); + dragDepthRef.current += 1; + setIsDragOverComposer(true); + }; + + const onComposerDragOver = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setIsDragOverComposer(true); + }; + + const onComposerDragLeave = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) { + return; + } + event.preventDefault(); + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return; + } + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) { + setIsDragOverComposer(false); + } + }; + + const onComposerDrop = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes("Files")) { + return; + } + event.preventDefault(); + dragDepthRef.current = 0; + setIsDragOverComposer(false); + const files = Array.from(event.dataTransfer.files); + addComposerImages(files); + focusComposer(); + }; + + return ( +
+
+
+ {activePendingApproval ? ( +
+ +
+ ) : pendingUserInputs.length > 0 ? ( +
+ +
+ ) : showPlanFollowUpPrompt && activeProposedPlan ? ( +
+ +
+ ) : null} + + {/* Textarea area */} +
+ {composerMenuOpen && !isComposerApprovalState && ( +
+ +
+ )} + + {!isComposerApprovalState && + pendingUserInputs.length === 0 && + composerImages.length > 0 && ( +
+ {composerImages.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
+ ))} +
+ )} + +
+ + {/* Bottom toolbar */} + {activePendingApproval ? ( +
+ +
+ ) : ( +
+
+ {/* Provider/model picker */} + + + {isComposerFooterCompact ? ( + + ) : ( + <> + {selectedProvider === "codex" && selectedEffort != null ? ( + <> + + + + ) : null} + + + + + + + + + + {activePlan || activeProposedPlan || planSidebarOpen ? ( + <> + + + + ) : null} + + )} +
+ + {/* Right side: send / stop button */} +
+ {isPreparingWorktree ? ( + Preparing worktree... + ) : null} + {activePendingProgress ? ( +
+ {activePendingProgress.questionIndex > 0 ? ( + + ) : null} + +
+ ) : phase === "running" ? ( + + ) : pendingUserInputs.length === 0 ? ( + showPlanFollowUpPrompt ? ( + prompt.trim().length > 0 ? ( + + ) : ( +
+ + + + } + > + + + + void onImplementPlanInNewThread()} + > + Implement in new thread + + + +
+ ) + ) : ( + + ) + ) : null} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/hooks/chat/useMessageListAutoScroll.tsx b/apps/web/src/hooks/chat/useMessageListAutoScroll.tsx new file mode 100644 index 000000000..947b05ea4 --- /dev/null +++ b/apps/web/src/hooks/chat/useMessageListAutoScroll.tsx @@ -0,0 +1,217 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { TimelineEntry } from "../../session-logic"; +import { isScrollContainerNearBottom } from "../../chat-scroll"; +import { SessionPhase } from "../../types"; + +type UseMessageListAutoScrollInput = { + activeThreadId: string | null; + messageCount: number; + phase: SessionPhase; + timelineEntries: TimelineEntry[]; +}; + +export function useMessageListAutoScroll({ + activeThreadId, + messageCount, + phase, + timelineEntries, +}: UseMessageListAutoScrollInput) { + const messagesScrollRef = useRef(null); + const [messagesScrollElement, setMessagesScrollElement] = useState(null); + const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { + messagesScrollRef.current = element; + setMessagesScrollElement(element); + }, []); + const shouldAutoScrollRef = useRef(true); + const lastKnownScrollTopRef = useRef(0); + const pendingAutoScrollFrameRef = useRef(null); + const isPointerScrollActiveRef = useRef(false); + const lastTouchClientYRef = useRef(null); + const pendingUserScrollUpIntentRef = useRef(false); + const pendingInteractionAnchorRef = useRef<{ + element: HTMLElement; + top: number; + } | null>(null); + const pendingInteractionAnchorFrameRef = useRef(null); + + const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer) return; + scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); + lastKnownScrollTopRef.current = scrollContainer.scrollTop; + shouldAutoScrollRef.current = true; + }, []); + const cancelPendingStickToBottom = useCallback(() => { + const pendingFrame = pendingAutoScrollFrameRef.current; + if (pendingFrame === null) return; + pendingAutoScrollFrameRef.current = null; + window.cancelAnimationFrame(pendingFrame); + }, []); + const cancelPendingInteractionAnchorAdjustment = useCallback(() => { + const pendingFrame = pendingInteractionAnchorFrameRef.current; + if (pendingFrame === null) return; + pendingInteractionAnchorFrameRef.current = null; + window.cancelAnimationFrame(pendingFrame); + }, []); + const scheduleStickToBottom = useCallback(() => { + if (pendingAutoScrollFrameRef.current !== null) return; + pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { + pendingAutoScrollFrameRef.current = null; + scrollMessagesToBottom(); + }); + }, [scrollMessagesToBottom]); + + const onMessagesClickCapture = useCallback( + (event: React.MouseEvent) => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer || !(event.target instanceof Element)) return; + + const trigger = event.target.closest( + "button, summary, [role='button'], [data-scroll-anchor-target]", + ); + if (!trigger || !scrollContainer.contains(trigger)) return; + if (trigger.closest("[data-scroll-anchor-ignore]")) return; + + pendingInteractionAnchorRef.current = { + element: trigger, + top: trigger.getBoundingClientRect().top, + }; + + cancelPendingInteractionAnchorAdjustment(); + pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => { + pendingInteractionAnchorFrameRef.current = null; + const anchor = pendingInteractionAnchorRef.current; + pendingInteractionAnchorRef.current = null; + const activeScrollContainer = messagesScrollRef.current; + if (!anchor || !activeScrollContainer) return; + if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return; + + const nextTop = anchor.element.getBoundingClientRect().top; + const delta = nextTop - anchor.top; + if (Math.abs(delta) < 0.5) return; + + activeScrollContainer.scrollTop += delta; + lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; + }); + }, + [cancelPendingInteractionAnchorAdjustment], + ); + + const forceStickToBottom = useCallback(() => { + cancelPendingStickToBottom(); + scrollMessagesToBottom(); + scheduleStickToBottom(); + }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); + const onMessagesScroll = useCallback(() => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer) return; + const currentScrollTop = scrollContainer.scrollTop; + const isNearBottom = isScrollContainerNearBottom(scrollContainer); + + if (!shouldAutoScrollRef.current && isNearBottom) { + shouldAutoScrollRef.current = true; + pendingUserScrollUpIntentRef.current = false; + } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { + const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; + if (scrolledUp) { + shouldAutoScrollRef.current = false; + } + pendingUserScrollUpIntentRef.current = false; + } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { + const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; + if (scrolledUp) { + shouldAutoScrollRef.current = false; + } + } else if (shouldAutoScrollRef.current && !isNearBottom) { + // Catch-all for keyboard/assistive scroll interactions. + const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; + if (scrolledUp) { + shouldAutoScrollRef.current = false; + } + } + + lastKnownScrollTopRef.current = currentScrollTop; + }, []); + const onMessagesWheel = useCallback((event: React.WheelEvent) => { + if (event.deltaY < 0) { + pendingUserScrollUpIntentRef.current = true; + } + }, []); + const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = true; + }, []); + const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = false; + }, []); + const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { + isPointerScrollActiveRef.current = false; + }, []); + const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + lastTouchClientYRef.current = touch.clientY; + }, []); + const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + const previousTouchY = lastTouchClientYRef.current; + if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { + pendingUserScrollUpIntentRef.current = true; + } + lastTouchClientYRef.current = touch.clientY; + }, []); + const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { + lastTouchClientYRef.current = null; + }, []); + + useEffect(() => { + return () => { + cancelPendingStickToBottom(); + cancelPendingInteractionAnchorAdjustment(); + }; + }, [cancelPendingInteractionAnchorAdjustment, cancelPendingStickToBottom]); + + useLayoutEffect(() => { + if (!activeThreadId) return; + shouldAutoScrollRef.current = true; + scheduleStickToBottom(); + const timeout = window.setTimeout(() => { + const scrollContainer = messagesScrollRef.current; + if (!scrollContainer) return; + if (isScrollContainerNearBottom(scrollContainer)) return; + scheduleStickToBottom(); + }, 96); + return () => { + window.clearTimeout(timeout); + }; + }, [activeThreadId, scheduleStickToBottom]); + + useEffect(() => { + if (!shouldAutoScrollRef.current) return; + scheduleStickToBottom(); + }, [messageCount, scheduleStickToBottom]); + useEffect(() => { + if (phase !== "running") return; + if (!shouldAutoScrollRef.current) return; + scheduleStickToBottom(); + }, [phase, scheduleStickToBottom, timelineEntries]); + + return { + scrollContainerRef: setMessagesScrollContainerRef, + scrollContainerElement: messagesScrollElement, + forceStickToBottom, + scheduleStickToBottom, + handlers: { + onMessagesClickCapture, + onMessagesScroll, + onMessagesWheel, + onMessagesPointerDown, + onMessagesPointerUp, + onMessagesPointerCancel, + onMessagesTouchStart, + onMessagesTouchMove, + onMessagesTouchEnd, + }, + shouldAutoScrollRef: shouldAutoScrollRef, + }; +}