FN-8344: preserve manual planner chat scroll position

Keep Planner Chat pinned to live output only while readers remain near the transcript tail.

- Track user scroll position and distinguish programmatic anchoring.
- Preserve manually unsnapped transcript positions during streamed updates.
- Add scroll-follow regression coverage and a patch changeset.

Files changed:
 .changeset/fn-8344-planner-chat-scroll-follow.md   |   7 ++
 .../app/components/TaskPlannerChatTab.tsx          |  63 +++++++++-
 .../__tests__/TaskPlannerChatTab.test.tsx          | 134 ++++++++++++++++++++-
 3 files changed, 198 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-8344

Fusion-Task-Lineage: fde7965c-cffc-4908-90c1-3df1f3c2af75

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-18 19:24:52 -07:00
parent 12aee016ee
commit 13540ee9c2
3 changed files with 198 additions and 6 deletions

View File

@@ -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.

View File

@@ -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<string | null>(null);
const streamRef = useRef<{ close: () => void } | null>(null);
const transcriptRef = useRef<HTMLDivElement | null>(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 ? <Minimize2 aria-hidden="true" /> : <Maximize2 aria-hidden="true" />}
</button>
)}
<div className="task-planner-chat-transcript" ref={transcriptRef} data-testid="task-planner-chat-transcript">
<div className="task-planner-chat-transcript" ref={transcriptRef} onScroll={handleTranscriptScroll} data-testid="task-planner-chat-transcript">
{error && <div className="task-planner-chat-error" role="alert">{error}</div>}
{loading ? (
<div className="task-planner-chat-state" role="status" aria-live="polite">

View File

@@ -1,12 +1,15 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import React from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
const taskPlannerChatCss = readFileSync(resolve(__dirname, "../TaskPlannerChatTab.css"), "utf8");
const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop");
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
const { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockAttachChatStream, mockEditChatMessage, mockAddSteeringComment, mockTranslations, mockT } = vi.hoisted(() => {
const translations = new Map<string, string>();
@@ -110,6 +113,48 @@ function renderPlannerChat(overrides: Partial<React.ComponentProps<typeof TaskPl
);
}
function restoreMetricDescriptor(name: "scrollTop" | "scrollHeight" | "clientHeight", descriptor: PropertyDescriptor | undefined) {
if (descriptor) {
Object.defineProperty(HTMLElement.prototype, name, descriptor);
return;
}
delete (HTMLElement.prototype as Record<string, unknown>)[name];
}
function mockPlannerTranscriptMetrics({ scrollHeight = 1200, clientHeight = 240, initialScrollTop = 0 } = {}) {
let scrollTopValue = initialScrollTop;
let scrollHeightValue = scrollHeight;
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
configurable: true,
get() {
return this instanceof HTMLElement && this.classList.contains("task-planner-chat-transcript") ? scrollHeightValue : 0;
},
});
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
configurable: true,
get() {
return this instanceof HTMLElement && this.classList.contains("task-planner-chat-transcript") ? clientHeight : 0;
},
});
Object.defineProperty(HTMLElement.prototype, "scrollTop", {
configurable: true,
get() {
return this instanceof HTMLElement && this.classList.contains("task-planner-chat-transcript") ? scrollTopValue : 0;
},
set(value) {
if (this instanceof HTMLElement && this.classList.contains("task-planner-chat-transcript")) {
scrollTopValue = Number(value);
}
},
});
return {
get scrollTop() { return scrollTopValue; },
set scrollTop(value: number) { scrollTopValue = value; },
get scrollHeight() { return scrollHeightValue; },
set scrollHeight(value: number) { scrollHeightValue = value; },
};
}
function plannerQuestionMessage(id: string, args: Record<string, unknown>, createdAt = "2026-06-30T00:02:00.000Z") {
return {
id,
@@ -138,6 +183,12 @@ describe("TaskPlannerChatTab", () => {
mockAddSteeringComment.mockResolvedValue(makeTask("FN-7310"));
});
afterEach(() => {
restoreMetricDescriptor("scrollTop", originalScrollTopDescriptor);
restoreMetricDescriptor("scrollHeight", originalScrollHeightDescriptor);
restoreMetricDescriptor("clientHeight", originalClientHeightDescriptor);
});
it("looks up an existing task-scoped planner session and renders the starter-prompt empty state", async () => {
renderPlannerChat();
@@ -600,6 +651,87 @@ describe("TaskPlannerChatTab", () => {
expect(screen.queryByRole("button", { name: /Summarize recent activity/ })).not.toBeInTheDocument();
});
it("keeps an unsnapped planner transcript in place during streamed growth", async () => {
const user = userEvent.setup();
const metrics = mockPlannerTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240 });
let streamHandlers: any;
mockFetchChatMessages.mockResolvedValueOnce({
messages: [{ id: "history", sessionId: "chat-planner", role: "assistant", content: "Earlier plan", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" }],
});
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
streamHandlers = handlers;
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
await screen.findByText("Earlier plan");
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
await user.type(screen.getByLabelText("Message planner chat"), "Keep streaming");
await user.click(screen.getByRole("button", { name: "Send" }));
metrics.scrollTop = 120;
fireEvent.scroll(screen.getByTestId("task-planner-chat-transcript"));
metrics.scrollHeight = 1400;
act(() => streamHandlers.onText("more streamed plan"));
expect(metrics.scrollTop).toBe(120);
expect(metrics.scrollTop).not.toBe(metrics.scrollHeight);
});
it("follows streamed planner growth while pinned and re-pins after returning to the bottom", async () => {
const user = userEvent.setup();
const metrics = mockPlannerTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240 });
let streamHandlers: any;
mockFetchChatMessages.mockResolvedValueOnce({
messages: [{ id: "history", sessionId: "chat-planner", role: "assistant", content: "Earlier plan", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" }],
});
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
streamHandlers = handlers;
return { close: vi.fn(), isConnected: () => true };
});
renderPlannerChat();
await screen.findByText("Earlier plan");
await user.type(screen.getByLabelText("Message planner chat"), "Keep streaming");
await user.click(screen.getByRole("button", { name: "Send" }));
metrics.scrollHeight = 1400;
act(() => streamHandlers.onText("pinned growth"));
expect(metrics.scrollTop).toBe(1400);
metrics.scrollTop = 120;
fireEvent.scroll(screen.getByTestId("task-planner-chat-transcript"));
metrics.scrollTop = 1160;
fireEvent.scroll(screen.getByTestId("task-planner-chat-transcript"));
metrics.scrollHeight = 1700;
act(() => streamHandlers.onText("re-pinned growth"));
expect(metrics.scrollTop).toBe(1700);
});
it("snaps populated planner history on first active render and resets an empty transcript to the top", async () => {
const metrics = mockPlannerTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 });
mockFetchChatMessages.mockResolvedValueOnce({
messages: [{ id: "history", sessionId: "chat-planner", role: "assistant", content: "Existing plan", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" }],
});
renderPlannerChat();
await screen.findByText("Existing plan");
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
metrics.scrollTop = 50;
mockFetchTaskPlannerChatSession.mockResolvedValueOnce({ session: null });
renderPlannerChat({ task: makeTask("FN-empty") });
await screen.findAllByTestId("task-planner-chat-empty");
expect(metrics.scrollTop).toBe(0);
});
it("keeps the mobile media block free of planner transcript scroll-semantic overrides", () => {
const mobileCss = taskPlannerChatCss.slice(taskPlannerChatCss.indexOf("@media (max-width: 768px)"));
expect(mobileCss).not.toMatch(/\.task-planner-chat-transcript\s*\{/);
expect(mobileCss).not.toMatch(/\.task-planner-chat-transcript[^}]*\b(?:overflow|scroll-behavior|height|flex)\s*:/);
});
it("sends messages through the chat stream and appends success responses", async () => {
const user = userEvent.setup();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {