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 previousEntryCountRef = useRef(0);
|
||||||
const previousScrollHeightRef = useRef(0);
|
const previousScrollHeightRef = useRef(0);
|
||||||
const previousActiveRef = useRef(false);
|
const previousActiveRef = useRef(false);
|
||||||
|
const anchorFrameRef = useRef<number | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const groups = useMemo(() => groupEntriesByAgent(entries), [entries]);
|
const groups = useMemo(() => groupEntriesByAgent(entries), [entries]);
|
||||||
@@ -359,6 +360,49 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
|
|||||||
resizeComposer();
|
resizeComposer();
|
||||||
}, [draft, 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(() => {
|
useLayoutEffect(() => {
|
||||||
const container = transcriptRef.current;
|
const container = transcriptRef.current;
|
||||||
const wasActive = previousActiveRef.current;
|
const wasActive = previousActiveRef.current;
|
||||||
@@ -369,10 +413,14 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
|
|||||||
const receivedInitialEntries = previousEntryCountRef.current === 0;
|
const receivedInitialEntries = previousEntryCountRef.current === 0;
|
||||||
if (!becameActive && !receivedInitialEntries) return;
|
if (!becameActive && !receivedInitialEntries) return;
|
||||||
|
|
||||||
container.scrollTop = container.scrollHeight;
|
anchorTranscriptToBottom(container);
|
||||||
previousEntryCountRef.current = entries.length;
|
previousEntryCountRef.current = entries.length;
|
||||||
previousScrollHeightRef.current = container.scrollHeight;
|
previousScrollHeightRef.current = container.scrollHeight;
|
||||||
}, [active, entries.length]);
|
|
||||||
|
return () => {
|
||||||
|
cancelAnchorTranscriptFrame();
|
||||||
|
};
|
||||||
|
}, [active, anchorTranscriptToBottom, cancelAnchorTranscriptFrame, entries.length]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const container = transcriptRef.current;
|
const container = transcriptRef.current;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const mockedAddSteeringComment = vi.mocked(addSteeringComment);
|
|||||||
const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop");
|
const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop");
|
||||||
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
|
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
|
||||||
const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||||
|
|
||||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
return {
|
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", () => {
|
describe("TaskChatTab", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -144,6 +187,16 @@ describe("TaskChatTab", () => {
|
|||||||
restoreMetricDescriptor("scrollTop", originalScrollTopDescriptor);
|
restoreMetricDescriptor("scrollTop", originalScrollTopDescriptor);
|
||||||
restoreMetricDescriptor("scrollHeight", originalScrollHeightDescriptor);
|
restoreMetricDescriptor("scrollHeight", originalScrollHeightDescriptor);
|
||||||
restoreMetricDescriptor("clientHeight", originalClientHeightDescriptor);
|
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", () => {
|
it("subscribes to live agent logs only when active", () => {
|
||||||
@@ -412,6 +465,61 @@ describe("TaskChatTab", () => {
|
|||||||
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
|
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", () => {
|
it("does not mutate scroll position for an empty transcript", () => {
|
||||||
const metrics = mockTranscriptMetrics({ scrollHeight: 900, clientHeight: 240, initialScrollTop: 25 });
|
const metrics = mockTranscriptMetrics({ scrollHeight: 900, clientHeight: 240, initialScrollTop: 25 });
|
||||||
mockLogs([]);
|
mockLogs([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user