FN-6337: keep task chat anchored after load
Reliably settles task-detail chat transcripts to the latest output after load and reactivation. - Add a bounded animation-frame settle loop that re-pins populated transcripts while late layout growth occurs. - Cancel pending settle frames on cleanup to avoid stale scroll writes after unmount or rerender. - Cover async transcript height growth and settle-loop cleanup in TaskChatTab tests. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-6337-chat-scroll-settle.md | 5 + packages/dashboard/app/components/TaskChatTab.tsx | 52 +++++++++- .../app/components/__tests__/TaskChatTab.test.tsx | 108 +++++++++++++++++++++ 3 files changed, 163 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-6337 Fusion-Task-Lineage: d31de363-7ee6-4098-ac43-991dc33192e4
This commit is contained in:
5
.changeset/fn-6337-chat-scroll-settle.md
Normal file
5
.changeset/fn-6337-chat-scroll-settle.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Reliably settle the task detail Chat transcript to the latest output on load and tab reactivation, including after collapsible thinking/tool groups reflow.
|
||||
@@ -339,6 +339,7 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
|
||||
const previousEntryCountRef = useRef(0);
|
||||
const previousScrollHeightRef = useRef(0);
|
||||
const previousActiveRef = useRef(false);
|
||||
const anchorFrameRef = useRef<number | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const groups = useMemo(() => groupEntriesByAgent(entries), [entries]);
|
||||
@@ -359,6 +360,49 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
|
||||
resizeComposer();
|
||||
}, [draft, resizeComposer]);
|
||||
|
||||
const cancelAnchorTranscriptFrame = useCallback(() => {
|
||||
if (anchorFrameRef.current === null) return;
|
||||
window.cancelAnimationFrame(anchorFrameRef.current);
|
||||
anchorFrameRef.current = null;
|
||||
}, []);
|
||||
|
||||
const anchorTranscriptToBottom = useCallback((container: HTMLElement) => {
|
||||
cancelAnchorTranscriptFrame();
|
||||
if (!container.isConnected) return;
|
||||
|
||||
let frame = 0;
|
||||
let stableFrames = 0;
|
||||
let lastScrollHeight = -1;
|
||||
const maxFrames = 6;
|
||||
|
||||
const writeBottom = () => {
|
||||
anchorFrameRef.current = null;
|
||||
if (!container.isConnected) return;
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
if (container.scrollHeight === lastScrollHeight) {
|
||||
stableFrames += 1;
|
||||
} else {
|
||||
stableFrames = 0;
|
||||
lastScrollHeight = container.scrollHeight;
|
||||
}
|
||||
|
||||
frame += 1;
|
||||
if (frame >= maxFrames || stableFrames >= 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
anchorFrameRef.current = window.requestAnimationFrame(writeBottom);
|
||||
};
|
||||
|
||||
writeBottom();
|
||||
}, [cancelAnchorTranscriptFrame]);
|
||||
|
||||
useLayoutEffect(() => () => {
|
||||
cancelAnchorTranscriptFrame();
|
||||
}, [cancelAnchorTranscriptFrame]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = transcriptRef.current;
|
||||
const wasActive = previousActiveRef.current;
|
||||
@@ -369,10 +413,14 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
|
||||
const receivedInitialEntries = previousEntryCountRef.current === 0;
|
||||
if (!becameActive && !receivedInitialEntries) return;
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
anchorTranscriptToBottom(container);
|
||||
previousEntryCountRef.current = entries.length;
|
||||
previousScrollHeightRef.current = container.scrollHeight;
|
||||
}, [active, entries.length]);
|
||||
|
||||
return () => {
|
||||
cancelAnchorTranscriptFrame();
|
||||
};
|
||||
}, [active, anchorTranscriptToBottom, cancelAnchorTranscriptFrame, entries.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = transcriptRef.current;
|
||||
|
||||
@@ -21,6 +21,8 @@ const mockedAddSteeringComment = vi.mocked(addSteeringComment);
|
||||
const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop");
|
||||
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
|
||||
const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
@@ -134,6 +136,47 @@ function mockMatchMedia(matches: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
function mockRequestAnimationFrame() {
|
||||
let nextId = 1;
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
callbacks.set(id, callback);
|
||||
return id;
|
||||
});
|
||||
const cancelAnimationFrame = vi.fn((id: number) => {
|
||||
callbacks.delete(id);
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "requestAnimationFrame", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: requestAnimationFrame,
|
||||
});
|
||||
Object.defineProperty(window, "cancelAnimationFrame", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: cancelAnimationFrame,
|
||||
});
|
||||
|
||||
return {
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
flushNext() {
|
||||
const next = callbacks.entries().next();
|
||||
if (next.done) return false;
|
||||
const [id, callback] = next.value;
|
||||
callbacks.delete(id);
|
||||
callback(performance.now());
|
||||
return true;
|
||||
},
|
||||
get pendingCount() {
|
||||
return callbacks.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("TaskChatTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -144,6 +187,16 @@ describe("TaskChatTab", () => {
|
||||
restoreMetricDescriptor("scrollTop", originalScrollTopDescriptor);
|
||||
restoreMetricDescriptor("scrollHeight", originalScrollHeightDescriptor);
|
||||
restoreMetricDescriptor("clientHeight", originalClientHeightDescriptor);
|
||||
Object.defineProperty(window, "requestAnimationFrame", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalRequestAnimationFrame,
|
||||
});
|
||||
Object.defineProperty(window, "cancelAnimationFrame", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalCancelAnimationFrame,
|
||||
});
|
||||
});
|
||||
|
||||
it("subscribes to live agent logs only when active", () => {
|
||||
@@ -412,6 +465,61 @@ describe("TaskChatTab", () => {
|
||||
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
|
||||
});
|
||||
|
||||
it("FN-6337: re-pins populated transcripts to the bottom after async height growth", () => {
|
||||
const raf = mockRequestAnimationFrame();
|
||||
const metrics = mockTranscriptMetrics({ scrollHeight: 600, clientHeight: 240, initialScrollTop: 0 });
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", text: "older output" }),
|
||||
makeEntry({ agent: "executor", type: "thinking", text: "expanded thinking", timestamp: "2026-06-12T00:00:01.000Z" }),
|
||||
makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test", timestamp: "2026-06-12T00:00:02.000Z" }),
|
||||
]);
|
||||
|
||||
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
|
||||
expect(metrics.scrollTop).toBe(600);
|
||||
metrics.scrollHeight = 900;
|
||||
expect(raf.flushNext()).toBe(true);
|
||||
expect(metrics.scrollTop).toBe(900);
|
||||
|
||||
metrics.scrollHeight = 1200;
|
||||
expect(raf.flushNext()).toBe(true);
|
||||
expect(metrics.scrollTop).toBe(1200);
|
||||
|
||||
expect(raf.flushNext()).toBe(true);
|
||||
expect(metrics.scrollTop).toBe(1200);
|
||||
expect(raf.flushNext()).toBe(true);
|
||||
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
|
||||
expect(raf.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("FN-6337: bounds and cleans up the settle loop", () => {
|
||||
const raf = mockRequestAnimationFrame();
|
||||
const metrics = mockTranscriptMetrics({ scrollHeight: 500, clientHeight: 240, initialScrollTop: 0 });
|
||||
mockLogs([makeEntry({ agent: "executor", text: "output" })]);
|
||||
|
||||
const { unmount } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
|
||||
for (let frame = 0; frame < 5; frame += 1) {
|
||||
metrics.scrollHeight += 100;
|
||||
expect(raf.flushNext()).toBe(true);
|
||||
}
|
||||
expect(metrics.scrollTop).toBe(1000);
|
||||
expect(raf.pendingCount).toBe(0);
|
||||
|
||||
metrics.scrollHeight = 1300;
|
||||
mockLogs([makeEntry({ agent: "executor", text: "output after remount" })]);
|
||||
const mountedAgain = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
expect(raf.pendingCount).toBe(1);
|
||||
mountedAgain.unmount();
|
||||
expect(raf.cancelAnimationFrame).toHaveBeenCalled();
|
||||
expect(raf.pendingCount).toBe(0);
|
||||
|
||||
metrics.scrollHeight = 1600;
|
||||
expect(raf.flushNext()).toBe(false);
|
||||
expect(metrics.scrollTop).toBe(1300);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("does not mutate scroll position for an empty transcript", () => {
|
||||
const metrics = mockTranscriptMetrics({ scrollHeight: 900, clientHeight: 240, initialScrollTop: 25 });
|
||||
mockLogs([]);
|
||||
|
||||
Reference in New Issue
Block a user