diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx index 86598776fb..1b6085dc83 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { PlanningQuestion } from "@fusion/core"; import type { CeActivityTurn, CeConversationTurn, CeSession } from "../session/session-store.js"; import { canRenderRichly } from "./ce-question-support.js"; @@ -39,6 +39,8 @@ export interface CeFlowProps { // ── Transcript parsing ─────────────────────────────────────────────────────── +const BOTTOM_FOLLOW_THRESHOLD_PX = 50; + type DisplayItem = | { kind: "chat"; role: "user" | "agent"; text: string } | { kind: "qa-question"; question: PlanningQuestion } @@ -63,6 +65,10 @@ function tryParseJson(text: string): Record | undefined { * renderable items. Control records are no longer hidden — questions, answers, * and working traces are the conversation. */ +function isNearTranscriptBottom(container: HTMLOListElement): boolean { + return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX; +} + function parseHistory(history: CeConversationTurn[]): DisplayItem[] { const items: DisplayItem[] = []; const questionsById = new Map(); @@ -152,9 +158,52 @@ function ActivityTrace({ turns, live }: { turns: CeActivityTurn[]; live?: boolea /** Render the full conversation: chat, Q&A bubbles, and working traces. */ function Transcript({ history }: { history: CeConversationTurn[] }) { const items = useMemo(() => parseHistory(history), [history]); + const transcriptRef = useRef(null); + const previousHistoryLengthRef = useRef(0); + const previousScrollHeightRef = useRef(0); + const [isFollowing, setIsFollowing] = useState(true); + + useLayoutEffect(() => { + const container = transcriptRef.current; + if (!container) return; + + const previousHistoryLength = previousHistoryLengthRef.current; + const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight; + const wasNearBottom = previousScrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX; + const firstContentLoad = previousHistoryLength === 0 && history.length > 0; + const newContentArrived = history.length !== previousHistoryLength; + + if (firstContentLoad || (newContentArrived && (isFollowing || wasNearBottom))) { + container.scrollTop = container.scrollHeight; + } + + previousHistoryLengthRef.current = history.length; + previousScrollHeightRef.current = container.scrollHeight; + setIsFollowing(isNearTranscriptBottom(container)); + }, [history, isFollowing]); + + const handleScroll = useCallback(() => { + const container = transcriptRef.current; + if (!container) return; + setIsFollowing(isNearTranscriptBottom(container)); + }, []); + + useEffect(() => { + if (typeof ResizeObserver === "undefined" || !isFollowing) return; + const container = transcriptRef.current; + if (!container) return; + + const observer = new ResizeObserver(() => { + container.scrollTop = container.scrollHeight; + }); + + observer.observe(container); + return () => observer.disconnect(); + }, [isFollowing]); + if (items.length === 0) return null; return ( -
    +
      {items.map((item, i) => { switch (item.kind) { case "chat": diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css index da1abd3bde..8aa771300a 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css @@ -1,9 +1,14 @@ .ce-view { display: flex; + flex: 1 1 auto; flex-direction: column; gap: 1rem; - padding: 1rem 1.25rem; + min-width: 0; + min-height: 0; + width: 100%; height: 100%; + padding: 1rem 1.25rem; + box-sizing: border-box; overflow: auto; } @@ -141,10 +146,22 @@ gap: 0.15rem; } +.ce-view[data-mobile="true"] { + width: 100%; + padding: var(--space-sm); +} + .ce-view[data-mobile="true"] .ce-groups { grid-template-columns: 1fr; } +@media (max-width: 768px) { + .ce-view { + width: 100%; + padding: var(--space-sm); + } +} + /* --- Stage launcher (U6) --- */ .ce-launcher { margin: 0.75rem 0; @@ -174,8 +191,11 @@ .ce-flow { margin: 0.75rem 0; display: flex; + flex: 1 1 auto; flex-direction: column; gap: 0.6rem; + min-height: 0; + overflow-y: auto; } .ce-flow-header { display: flex; @@ -199,9 +219,10 @@ margin: 0; padding: 0; display: flex; + flex: 1 1 auto; flex-direction: column; gap: 0.35rem; - max-height: 320px; + min-height: 0; overflow-y: auto; } .ce-flow-turn { @@ -375,9 +396,10 @@ margin: 0 0 0.8rem; padding: 0; display: flex; + flex: 1 1 auto; flex-direction: column; gap: 0.45rem; - max-height: 50vh; + min-height: 0; overflow-y: auto; } .ce-flow-turn { diff --git a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CeFlow.test.tsx b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CeFlow.test.tsx index 0b544e33af..6929026d39 100644 --- a/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CeFlow.test.tsx +++ b/plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CeFlow.test.tsx @@ -1,9 +1,71 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import type { PlanningQuestion } from "@fusion/core"; import { CeFlow } from "../CeFlow.js"; import type { CeSession } from "../../session/session-store.js"; +let restoreScrollProperties: (() => void) | undefined; + +function installTranscriptScrollBox(overrides: { scrollHeight: number; clientHeight: number; scrollTop: number }) { + restoreScrollProperties?.(); + + const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); + const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); + const originalScrollTop = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop"); + const state = { ...overrides }; + + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get: () => state.scrollHeight, + }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get: () => state.clientHeight, + }); + Object.defineProperty(HTMLElement.prototype, "scrollTop", { + configurable: true, + get: () => state.scrollTop, + set: (value: number) => { + state.scrollTop = value; + }, + }); + + restoreScrollProperties = () => { + if (originalScrollHeight) { + Object.defineProperty(HTMLElement.prototype, "scrollHeight", originalScrollHeight); + } else { + delete (HTMLElement.prototype as { scrollHeight?: number }).scrollHeight; + } + if (originalClientHeight) { + Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight); + } else { + delete (HTMLElement.prototype as { clientHeight?: number }).clientHeight; + } + if (originalScrollTop) { + Object.defineProperty(HTMLElement.prototype, "scrollTop", originalScrollTop); + } else { + delete (HTMLElement.prototype as { scrollTop?: number }).scrollTop; + } + restoreScrollProperties = undefined; + }; + + return { + get scrollTop() { + return state.scrollTop; + }, + setScrollHeight(value: number) { + state.scrollHeight = value; + }, + setScrollTop(value: number) { + state.scrollTop = value; + }, + }; +} + +afterEach(() => { + restoreScrollProperties?.(); +}); + function makeSession(over: Partial & { currentQuestion?: PlanningQuestion | null }): CeSession { return { id: "s1", @@ -254,6 +316,74 @@ describe("CeFlow — Q&A transcript rendering", () => { expect(screen.getByText("Scanning the repo…")).toBeInTheDocument(); expect(screen.getByTestId("ce-activity-tool")).toHaveTextContent("Read"); }); + + it("keeps the transcript pinned when new history arrives near the bottom", () => { + const scrollBox = installTranscriptScrollBox({ scrollHeight: 1000, clientHeight: 200, scrollTop: 800 }); + const initialHistory = [{ role: "agent" as const, text: "First answer", at: "t1" }]; + const { rerender } = render( + , + ); + expect(screen.getByTestId("ce-flow-transcript")).toHaveTextContent("First answer"); + + scrollBox.setScrollTop(800); + scrollBox.setScrollHeight(1200); + rerender( + , + ); + + expect(scrollBox.scrollTop).toBe(1200); + }); + + it("does not auto-scroll when the user has scrolled away from the bottom", () => { + const scrollBox = installTranscriptScrollBox({ scrollHeight: 1000, clientHeight: 200, scrollTop: 800 }); + const initialHistory = [{ role: "agent" as const, text: "First answer", at: "t1" }]; + const { rerender } = render( + , + ); + const transcript = screen.getByTestId("ce-flow-transcript"); + + scrollBox.setScrollTop(200); + fireEvent.scroll(transcript); + scrollBox.setScrollHeight(1200); + rerender( + , + ); + + expect(scrollBox.scrollTop).toBe(200); + }); + + it("scrolls to the bottom on first content load", () => { + const scrollBox = installTranscriptScrollBox({ scrollHeight: 900, clientHeight: 200, scrollTop: 0 }); + const { rerender } = render( + , + ); + expect(screen.queryByTestId("ce-flow-transcript")).not.toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByTestId("ce-flow-transcript")).toHaveTextContent("Loaded answer"); + expect(scrollBox.scrollTop).toBe(900); + }); }); describe("CeFlow — lifecycle surfaces", () => {