FN-6120: make Compound Engineering interview full width and auto-scroll

Keep the Compound Engineering interview transcript full-width and pinned to the latest messages when appropriate.

- expand the Compound Engineering view and transcript containers so the interview flow can fill the available panel width and height
- auto-scroll the transcript on first load and while new messages arrive if the viewer is still following the bottom
- preserve user scroll position when they scroll away from the bottom and cover the transcript follow behavior with tests

Files changed:
 plugins/fusion-plugin-compound-engineering/src/dashboard/CeFlow.tsx                  |  53 ++++++++-
 plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.css |  28 ++++-
 plugins/fusion-plugin-compound-engineering/src/dashboard/__tests__/CeFlow.test.tsx   | 132 ++++++++++++++++++++-
 3 files changed, 207 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-6120

Fusion-Task-Lineage: 1f7152f3-5af0-4db3-a767-96bca06b93c3
This commit is contained in:
gsxdsm
2026-06-09 13:37:37 -07:00
parent b872b37d72
commit 1315bc0fc3
3 changed files with 207 additions and 6 deletions

View File

@@ -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<string, unknown> | 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<string, PlanningQuestion>();
@@ -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<HTMLOListElement | null>(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 (
<ol className="ce-flow-transcript" data-testid="ce-flow-transcript">
<ol ref={transcriptRef} className="ce-flow-transcript" data-testid="ce-flow-transcript" onScroll={handleScroll}>
{items.map((item, i) => {
switch (item.kind) {
case "chat":

View File

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

View File

@@ -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<CeSession> & { 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(
<CeFlow session={makeSession({ status: "active", conversationHistory: initialHistory })} onAnswer={vi.fn()} />,
);
expect(screen.getByTestId("ce-flow-transcript")).toHaveTextContent("First answer");
scrollBox.setScrollTop(800);
scrollBox.setScrollHeight(1200);
rerender(
<CeFlow
session={makeSession({
status: "active",
conversationHistory: [...initialHistory, { role: "agent" as const, text: "Second answer", at: "t2" }],
})}
onAnswer={vi.fn()}
/>,
);
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(
<CeFlow session={makeSession({ status: "active", conversationHistory: initialHistory })} onAnswer={vi.fn()} />,
);
const transcript = screen.getByTestId("ce-flow-transcript");
scrollBox.setScrollTop(200);
fireEvent.scroll(transcript);
scrollBox.setScrollHeight(1200);
rerender(
<CeFlow
session={makeSession({
status: "active",
conversationHistory: [...initialHistory, { role: "agent" as const, text: "Second answer", at: "t2" }],
})}
onAnswer={vi.fn()}
/>,
);
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(
<CeFlow session={makeSession({ status: "active", conversationHistory: [] })} onAnswer={vi.fn()} />,
);
expect(screen.queryByTestId("ce-flow-transcript")).not.toBeInTheDocument();
rerender(
<CeFlow
session={makeSession({
status: "active",
conversationHistory: [{ role: "agent" as const, text: "Loaded answer", at: "t1" }],
})}
onAnswer={vi.fn()}
/>,
);
expect(screen.getByTestId("ce-flow-transcript")).toHaveTextContent("Loaded answer");
expect(scrollBox.scrollTop).toBe(900);
});
});
describe("CeFlow — lifecycle surfaces", () => {