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;
|
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[] = [
|
const TASK_PLANNER_CHAT_STARTER_PROMPTS: StarterPromptDefinition[] = [
|
||||||
{
|
{
|
||||||
id: "recent-activity",
|
id: "recent-activity",
|
||||||
@@ -314,6 +320,11 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const streamRef = useRef<{ close: () => void } | null>(null);
|
const streamRef = useRef<{ close: () => void } | null>(null);
|
||||||
const transcriptRef = useRef<HTMLDivElement | 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 loadRequestRef = useRef(0);
|
||||||
const streamRequestRef = useRef(0);
|
const streamRequestRef = useRef(0);
|
||||||
const addToastRef = useRef(addToast);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!transcriptRef.current) return;
|
const container = transcriptRef.current;
|
||||||
|
const wasActive = previousActiveRef.current;
|
||||||
|
previousActiveRef.current = active;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
transcriptRef.current.scrollTop = 0;
|
container.scrollTop = 0;
|
||||||
|
previousMessageCountRef.current = 0;
|
||||||
|
setTranscriptAtBottom(true);
|
||||||
return;
|
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 sendMessageContent = useCallback(async (messageContent: string) => {
|
||||||
const content = messageContent.trim();
|
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" />}
|
{expanded ? <Minimize2 aria-hidden="true" /> : <Maximize2 aria-hidden="true" />}
|
||||||
</button>
|
</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>}
|
{error && <div className="task-planner-chat-error" role="alert">{error}</div>}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="task-planner-chat-state" role="status" aria-live="polite">
|
<div className="task-planner-chat-state" role="status" aria-live="polite">
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
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 React from "react";
|
||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
|
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
|
||||||
|
|
||||||
const taskPlannerChatCss = readFileSync(resolve(__dirname, "../TaskPlannerChatTab.css"), "utf8");
|
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 { mockEnsureTaskPlannerChatSession, mockFetchTaskPlannerChatSession, mockFetchChatSession, mockFetchChatMessages, mockFetchTaskDetail, mockStreamChatResponse, mockAttachChatStream, mockEditChatMessage, mockAddSteeringComment, mockTranslations, mockT } = vi.hoisted(() => {
|
||||||
const translations = new Map<string, string>();
|
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") {
|
function plannerQuestionMessage(id: string, args: Record<string, unknown>, createdAt = "2026-06-30T00:02:00.000Z") {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -138,6 +183,12 @@ describe("TaskPlannerChatTab", () => {
|
|||||||
mockAddSteeringComment.mockResolvedValue(makeTask("FN-7310"));
|
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 () => {
|
it("looks up an existing task-scoped planner session and renders the starter-prompt empty state", async () => {
|
||||||
renderPlannerChat();
|
renderPlannerChat();
|
||||||
|
|
||||||
@@ -600,6 +651,87 @@ describe("TaskPlannerChatTab", () => {
|
|||||||
expect(screen.queryByRole("button", { name: /Summarize recent activity/ })).not.toBeInTheDocument();
|
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 () => {
|
it("sends messages through the chat stream and appends success responses", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user