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:
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user