diff --git a/.changeset/fn-8344-planner-chat-scroll-follow.md b/.changeset/fn-8344-planner-chat-scroll-follow.md new file mode 100644 index 0000000000..1f96f9ce81 --- /dev/null +++ b/.changeset/fn-8344-planner-chat-scroll-follow.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Allow manual scrolling during generation in task Planner Chat. +category: fix +dev: Planner Chat now follows streamed output only while the transcript remains pinned near its tail. diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index 9a717e851e..ff88d9985b 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -43,6 +43,12 @@ interface StarterPromptDefinition { messageFallback: string; } +const BOTTOM_FOLLOW_THRESHOLD = 48; + +function isTranscriptNearBottom(container: HTMLElement): boolean { + return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD; +} + const TASK_PLANNER_CHAT_STARTER_PROMPTS: StarterPromptDefinition[] = [ { id: "recent-activity", @@ -314,6 +320,11 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, const [error, setError] = useState(null); const streamRef = useRef<{ close: () => void } | null>(null); const transcriptRef = useRef(null); + const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true); + const isTranscriptAtBottomRef = useRef(true); + const previousMessageCountRef = useRef(0); + const previousActiveRef = useRef(false); + const isProgrammaticTranscriptScrollRef = useRef(false); const loadRequestRef = useRef(0); const streamRequestRef = useRef(0); const addToastRef = useRef(addToast); @@ -589,14 +600,56 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, }; }, []); + const setTranscriptAtBottom = useCallback((atBottom: boolean) => { + isTranscriptAtBottomRef.current = atBottom; + setIsTranscriptAtBottom(atBottom); + }, []); + + const anchorTranscriptToBottom = useCallback((container: HTMLElement) => { + // Assignment does not normally emit scroll, but preserve the user-pinned state if a host does. + isProgrammaticTranscriptScrollRef.current = true; + try { + container.scrollTop = container.scrollHeight; + setTranscriptAtBottom(true); + } finally { + isProgrammaticTranscriptScrollRef.current = false; + } + }, [setTranscriptAtBottom]); + + const handleTranscriptScroll = useCallback(() => { + if (isProgrammaticTranscriptScrollRef.current) return; + const container = transcriptRef.current; + if (!container) return; + setTranscriptAtBottom(isTranscriptNearBottom(container)); + }, [setTranscriptAtBottom]); + useEffect(() => { - if (!transcriptRef.current) return; + const container = transcriptRef.current; + const wasActive = previousActiveRef.current; + previousActiveRef.current = active; + if (!container) return; + if (messages.length === 0) { - transcriptRef.current.scrollTop = 0; + container.scrollTop = 0; + previousMessageCountRef.current = 0; + setTranscriptAtBottom(true); return; } - transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; - }, [messages, composerState]); + + const becameActive = active && !wasActive; + const receivedInitialMessages = previousMessageCountRef.current === 0; + /* + * FNXC:TaskDetailPlannerChat 2026-07-18-16:10: + * FN-8344 applies FN-8339's TaskChatTab sticky-bottom/manual-unsnap invariant to + * Planner Chat. Initial history and tab activation intentionally anchor the reader, + * while streamed snapshots follow only when the reader remains within the 48px tail + * threshold so manually reading earlier planner output is never overridden. + */ + if (active && (becameActive || receivedInitialMessages || (isTranscriptAtBottomRef.current && isTranscriptAtBottom))) { + anchorTranscriptToBottom(container); + } + previousMessageCountRef.current = messages.length; + }, [active, anchorTranscriptToBottom, composerState, isTranscriptAtBottom, messages, setTranscriptAtBottom]); const sendMessageContent = useCallback(async (messageContent: string) => { const content = messageContent.trim(); @@ -961,7 +1014,7 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false, {expanded ?