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:
7
.changeset/fn-8344-planner-chat-scroll-follow.md
Normal file
7
.changeset/fn-8344-planner-chat-scroll-follow.md
Normal 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.
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user