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:
gsxdsm
2026-06-12 22:44:17 -07:00
parent d5b45c8257
commit f2054d0fa9
3 changed files with 163 additions and 2 deletions

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

View File

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

View File

@@ -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([]);