diff --git a/.changeset/fn-6315-chat-scroll-bottom.md b/.changeset/fn-6315-chat-scroll-bottom.md new file mode 100644 index 0000000000..c2cfe2fbb7 --- /dev/null +++ b/.changeset/fn-6315-chat-scroll-bottom.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 543c7de316..5c02ae9896 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -728,7 +728,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig ### 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: diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index 0800b9977a..a57e7e5c8a 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -151,6 +151,7 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr const transcriptRef = useRef(null); const previousEntryCountRef = useRef(0); const previousScrollHeightRef = useRef(0); + const previousActiveRef = useRef(false); const textareaRef = useRef(null); const groups = useMemo(() => groupEntriesByAgent(entries), [entries]); @@ -171,10 +172,31 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr 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(() => { const container = transcriptRef.current; if (!container) return; + if (!active) { + previousEntryCountRef.current = entries.length; + previousScrollHeightRef.current = container.scrollHeight; + return; + } + const previousCount = previousEntryCountRef.current; const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight; if (entries.length > previousCount) { @@ -186,7 +208,7 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr previousEntryCountRef.current = entries.length; previousScrollHeightRef.current = container.scrollHeight; - }, [entries]); + }, [active, entries]); const handleTranscriptScroll = useCallback(() => { const container = transcriptRef.current; @@ -223,6 +245,7 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr ref={transcriptRef} onScroll={handleTranscriptScroll} aria-live="polite" + data-testid="task-chat-transcript" > {loading && entries.length === 0 ? (
diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 49a8de1031..1f36049eae 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -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 { userEvent } from "@testing-library/user-event"; import { readFileSync } from "node:fs"; @@ -18,6 +18,9 @@ vi.mock("../../api", () => ({ const mockedUseAgentLogs = vi.mocked(useAgentLogs); 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 { 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)[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", () => { beforeEach(() => { vi.clearAllMocks(); mockLogs(); }); + afterEach(() => { + restoreMetricDescriptor("scrollTop", originalScrollTopDescriptor); + restoreMetricDescriptor("scrollHeight", originalScrollHeightDescriptor); + restoreMetricDescriptor("clientHeight", originalClientHeightDescriptor); + }); + it("subscribes to live agent logs only when active", () => { render(); expect(mockedUseAgentLogs).toHaveBeenCalledWith("FN-001", false, "project-1"); @@ -135,6 +219,102 @@ describe("TaskChatTab", () => { 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(); + + 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(); + expect(metrics.scrollTop).toBe(0); + + rerender(); + + 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(); + expect(metrics.scrollTop).toBe(0); + + rerender(); + + 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(); + + 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(); + expect(metrics.scrollTop).toBe(1000); + + metrics.scrollTop = 720; + fireEvent.scroll(screen.getByTestId("task-chat-transcript")); + metrics.scrollHeight = 1400; + rerender(); + + 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(); + expect(metrics.scrollTop).toBe(1000); + + metrics.scrollTop = 120; + fireEvent.scroll(screen.getByTestId("task-chat-transcript")); + metrics.scrollHeight = 1400; + rerender(); + + expect(metrics.scrollTop).toBe(120); + }); + it("posts composer text through addSteeringComment and clears on success", async () => { const user = userEvent.setup(); mockedAddSteeringComment.mockResolvedValue(makeTask());