FN-6315: Scroll task chat to latest output
Ensure the task-details Chat transcript opens at the newest agent output without breaking live scroll-away behavior. - Track active-state transitions and initial log population to snap populated transcripts to the bottom. - Preserve existing near-bottom live-follow behavior and inactive cached scroll bookkeeping. - Cover initial desktop/mobile load, reactivation, delayed population, and empty transcript cases. - Document the updated Chat tab behavior and add a published package patch changeset. Files changed: .changeset/fn-6315-chat-scroll-bottom.md | 5 + docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/TaskChatTab.tsx | 25 ++- .../app/components/__tests__/TaskChatTab.test.tsx | 182 ++++++++++++++++++++- 4 files changed, 211 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-6315 Fusion-Task-Lineage: 6cd7c418-124f-4730-a533-cc866f9a16a8
This commit is contained in:
5
.changeset/fn-6315-chat-scroll-bottom.md
Normal file
5
.changeset/fn-6315-chat-scroll-bottom.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix the task details Chat tab so it opens and reactivates at the latest agent output while preserving scroll-away behavior for live updates.
|
||||||
@@ -728,7 +728,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
|
|||||||
|
|
||||||
### Logs → Agent Log view
|
### Logs → Agent Log view
|
||||||
|
|
||||||
The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. The transcript follows new live output when you are already near the bottom, but it preserves your scroll position when you review older messages. For active, assigned, non-paused agent sessions in `in-progress` or `in-review` (reviewing/merging/fixing) tasks, the composer sends guidance to the running agent through the same steering path used by comments; when no active session is available, the composer is disabled with an explanatory hint.
|
The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. For active, assigned, non-paused agent sessions in `in-progress` or `in-review` (reviewing/merging/fixing) tasks, the composer sends guidance to the running agent through the same steering path used by comments; when no active session is available, the composer is disabled with an explanatory hint.
|
||||||
|
|
||||||
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:
|
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
|
|||||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||||
const previousEntryCountRef = useRef(0);
|
const previousEntryCountRef = useRef(0);
|
||||||
const previousScrollHeightRef = useRef(0);
|
const previousScrollHeightRef = useRef(0);
|
||||||
|
const previousActiveRef = useRef(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const groups = useMemo(() => groupEntriesByAgent(entries), [entries]);
|
const groups = useMemo(() => groupEntriesByAgent(entries), [entries]);
|
||||||
@@ -171,10 +172,31 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
|
|||||||
resizeComposer();
|
resizeComposer();
|
||||||
}, [draft, resizeComposer]);
|
}, [draft, resizeComposer]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = transcriptRef.current;
|
||||||
|
const wasActive = previousActiveRef.current;
|
||||||
|
previousActiveRef.current = active;
|
||||||
|
if (!container || !active || entries.length === 0) return;
|
||||||
|
|
||||||
|
const becameActive = !wasActive;
|
||||||
|
const receivedInitialEntries = previousEntryCountRef.current === 0;
|
||||||
|
if (!becameActive && !receivedInitialEntries) return;
|
||||||
|
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
previousEntryCountRef.current = entries.length;
|
||||||
|
previousScrollHeightRef.current = container.scrollHeight;
|
||||||
|
}, [active, entries.length]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const container = transcriptRef.current;
|
const container = transcriptRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
previousEntryCountRef.current = entries.length;
|
||||||
|
previousScrollHeightRef.current = container.scrollHeight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const previousCount = previousEntryCountRef.current;
|
const previousCount = previousEntryCountRef.current;
|
||||||
const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight;
|
const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight;
|
||||||
if (entries.length > previousCount) {
|
if (entries.length > previousCount) {
|
||||||
@@ -186,7 +208,7 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
|
|||||||
|
|
||||||
previousEntryCountRef.current = entries.length;
|
previousEntryCountRef.current = entries.length;
|
||||||
previousScrollHeightRef.current = container.scrollHeight;
|
previousScrollHeightRef.current = container.scrollHeight;
|
||||||
}, [entries]);
|
}, [active, entries]);
|
||||||
|
|
||||||
const handleTranscriptScroll = useCallback(() => {
|
const handleTranscriptScroll = useCallback(() => {
|
||||||
const container = transcriptRef.current;
|
const container = transcriptRef.current;
|
||||||
@@ -223,6 +245,7 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
|
|||||||
ref={transcriptRef}
|
ref={transcriptRef}
|
||||||
onScroll={handleTranscriptScroll}
|
onScroll={handleTranscriptScroll}
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
data-testid="task-chat-transcript"
|
||||||
>
|
>
|
||||||
{loading && entries.length === 0 ? (
|
{loading && entries.length === 0 ? (
|
||||||
<div className="task-chat-empty" role="status">
|
<div className="task-chat-empty" role="status">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { userEvent } from "@testing-library/user-event";
|
import { userEvent } from "@testing-library/user-event";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
@@ -18,6 +18,9 @@ vi.mock("../../api", () => ({
|
|||||||
|
|
||||||
const mockedUseAgentLogs = vi.mocked(useAgentLogs);
|
const mockedUseAgentLogs = vi.mocked(useAgentLogs);
|
||||||
const mockedAddSteeringComment = vi.mocked(addSteeringComment);
|
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");
|
||||||
|
|
||||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
return {
|
return {
|
||||||
@@ -56,12 +59,93 @@ function mockLogs(entries: AgentLogEntry[] = [], loading = false) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 mockTranscriptMetrics({
|
||||||
|
scrollHeight = 1200,
|
||||||
|
clientHeight = 240,
|
||||||
|
initialScrollTop = 0,
|
||||||
|
}: {
|
||||||
|
scrollHeight?: number;
|
||||||
|
clientHeight?: number;
|
||||||
|
initialScrollTop?: number;
|
||||||
|
} = {}) {
|
||||||
|
let scrollTopValue = initialScrollTop;
|
||||||
|
let scrollHeightValue = scrollHeight;
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return this instanceof HTMLElement && this.classList.contains("task-chat-transcript") ? scrollHeightValue : 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return this instanceof HTMLElement && this.classList.contains("task-chat-transcript") ? clientHeight : 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "scrollTop", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
return this instanceof HTMLElement && this.classList.contains("task-chat-transcript") ? scrollTopValue : 0;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
if (this instanceof HTMLElement && this.classList.contains("task-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 mockMatchMedia(matches: boolean) {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("TaskChatTab", () => {
|
describe("TaskChatTab", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockLogs();
|
mockLogs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreMetricDescriptor("scrollTop", originalScrollTopDescriptor);
|
||||||
|
restoreMetricDescriptor("scrollHeight", originalScrollHeightDescriptor);
|
||||||
|
restoreMetricDescriptor("clientHeight", originalClientHeightDescriptor);
|
||||||
|
});
|
||||||
|
|
||||||
it("subscribes to live agent logs only when active", () => {
|
it("subscribes to live agent logs only when active", () => {
|
||||||
render(<TaskChatTab task={makeTask()} active={false} projectId="project-1" addToast={vi.fn()} />);
|
render(<TaskChatTab task={makeTask()} active={false} projectId="project-1" addToast={vi.fn()} />);
|
||||||
expect(mockedUseAgentLogs).toHaveBeenCalledWith("FN-001", false, "project-1");
|
expect(mockedUseAgentLogs).toHaveBeenCalledWith("FN-001", false, "project-1");
|
||||||
@@ -135,6 +219,102 @@ describe("TaskChatTab", () => {
|
|||||||
expect(screen.getByText("second live chunk")).toBeTruthy();
|
expect(screen.getByText("second live chunk")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["desktop", false],
|
||||||
|
["mobile", true],
|
||||||
|
])("snaps populated transcripts to the bottom on initial %s render", (_label, matchesMobile) => {
|
||||||
|
mockMatchMedia(matchesMobile);
|
||||||
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1400, clientHeight: 240, initialScrollTop: 0 });
|
||||||
|
mockLogs([
|
||||||
|
makeEntry({ agent: "executor", text: "older output" }),
|
||||||
|
makeEntry({ agent: "executor", text: "latest output", timestamp: "2026-06-12T00:00:01.000Z" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("task-chat-transcript")).toBeTruthy();
|
||||||
|
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snaps to the bottom when the tab reactivates with unchanged cached entries", () => {
|
||||||
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 });
|
||||||
|
const cachedEntries = [
|
||||||
|
makeEntry({ agent: "executor", text: "cached first" }),
|
||||||
|
makeEntry({ agent: "executor", text: "cached latest", timestamp: "2026-06-12T00:00:01.000Z" }),
|
||||||
|
];
|
||||||
|
mockLogs(cachedEntries);
|
||||||
|
|
||||||
|
const { rerender } = render(<TaskChatTab task={makeTask()} active={false} addToast={vi.fn()} />);
|
||||||
|
expect(metrics.scrollTop).toBe(0);
|
||||||
|
|
||||||
|
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snaps when entries first become populated after an active empty render", () => {
|
||||||
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1100, clientHeight: 240, initialScrollTop: 0 });
|
||||||
|
const loadedEntries = [makeEntry({ agent: "executor", text: "loaded output" })];
|
||||||
|
mockedUseAgentLogs
|
||||||
|
.mockReturnValueOnce({ entries: [], loading: true, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 0, loadingMore: false })
|
||||||
|
.mockReturnValueOnce({ entries: loadedEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false });
|
||||||
|
|
||||||
|
const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||||
|
expect(metrics.scrollTop).toBe(0);
|
||||||
|
|
||||||
|
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate scroll position for an empty transcript", () => {
|
||||||
|
const metrics = mockTranscriptMetrics({ scrollHeight: 900, clientHeight: 240, initialScrollTop: 25 });
|
||||||
|
mockLogs([]);
|
||||||
|
|
||||||
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/No agent output yet/)).toBeTruthy();
|
||||||
|
expect(metrics.scrollTop).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues following new entries when the user is near the bottom", () => {
|
||||||
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 0 });
|
||||||
|
const firstEntries = [makeEntry({ agent: "executor", text: "first output" })];
|
||||||
|
const secondEntries = [...firstEntries, makeEntry({ agent: "executor", text: "second output", timestamp: "2026-06-12T00:00:01.000Z" })];
|
||||||
|
mockedUseAgentLogs
|
||||||
|
.mockReturnValueOnce({ entries: firstEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false })
|
||||||
|
.mockReturnValueOnce({ entries: secondEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 2, loadingMore: false });
|
||||||
|
|
||||||
|
const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||||
|
expect(metrics.scrollTop).toBe(1000);
|
||||||
|
|
||||||
|
metrics.scrollTop = 720;
|
||||||
|
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
||||||
|
metrics.scrollHeight = 1400;
|
||||||
|
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(metrics.scrollTop).toBe(1400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not yank a scrolled-up user when a new entry arrives", () => {
|
||||||
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 0 });
|
||||||
|
const firstEntries = [makeEntry({ agent: "executor", text: "first output" })];
|
||||||
|
const secondEntries = [...firstEntries, makeEntry({ agent: "executor", text: "second output", timestamp: "2026-06-12T00:00:01.000Z" })];
|
||||||
|
mockedUseAgentLogs
|
||||||
|
.mockReturnValueOnce({ entries: firstEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false })
|
||||||
|
.mockReturnValueOnce({ entries: secondEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 2, loadingMore: false });
|
||||||
|
|
||||||
|
const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||||
|
expect(metrics.scrollTop).toBe(1000);
|
||||||
|
|
||||||
|
metrics.scrollTop = 120;
|
||||||
|
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
||||||
|
metrics.scrollHeight = 1400;
|
||||||
|
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(metrics.scrollTop).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
it("posts composer text through addSteeringComment and clears on success", async () => {
|
it("posts composer text through addSteeringComment and clears on success", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
mockedAddSteeringComment.mockResolvedValue(makeTask());
|
mockedAddSteeringComment.mockResolvedValue(makeTask());
|
||||||
|
|||||||
Reference in New Issue
Block a user