FN-6425: pin task chat expand control
Keep the task chat expand action visible as an icon-only overlay inside the chat view. - Move the expand/collapse button out of the scrolling transcript and onto the task chat container. - Style the control as a centered icon-only button with stable overlay positioning. - Extend TaskChatTab coverage for empty, loading, populated, expanded, and scrolled transcript states. Files changed: packages/dashboard/app/components/TaskChatTab.css | 10 +++- packages/dashboard/app/components/TaskChatTab.tsx | 26 +++++----- .../app/components/__tests__/TaskChatTab.test.tsx | 58 +++++++++++++++++----- 3 files changed, 68 insertions(+), 26 deletions(-) Fusion-Task-Id: FN-6425 Fusion-Task-Lineage: 6526eb4a-4be9-472b-9595-c7fcea6971b1
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
.task-chat-tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
@@ -11,9 +12,16 @@
|
||||
.task-chat-expand-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
justify-content: center;
|
||||
min-inline-size: var(--space-2xl);
|
||||
min-block-size: var(--space-2xl);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskChat 2026-06-13-00:00:
|
||||
FN-6425 requires the chat expand control to stay inside the chat view as an icon-only affordance while remaining reachable at every transcript scroll offset. Anchor it to the non-scrolling task-chat wrapper rather than the transcript content.
|
||||
*/
|
||||
.task-chat-expand-toggle--overlay {
|
||||
position: absolute;
|
||||
top: var(--space-md);
|
||||
|
||||
@@ -621,6 +621,19 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
||||
|
||||
return (
|
||||
<div className="task-chat-tab" data-testid="task-chat-tab">
|
||||
{onToggleExpanded ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm task-chat-expand-toggle task-chat-expand-toggle--overlay"
|
||||
onClick={onToggleExpanded}
|
||||
aria-label={expanded ? "Collapse chat" : "Expand chat to full modal"}
|
||||
aria-pressed={expanded}
|
||||
data-testid="task-chat-expand-toggle"
|
||||
>
|
||||
{/* FNXC:TaskChat 2026-06-13-00:00: FN-6425 refines FN-6405 by keeping the task-chat expand affordance icon-only and pinned to the chat view corner so transcript scrolling never removes access to expansion controls. */}
|
||||
{expanded ? <Minimize2 aria-hidden="true" /> : <Maximize2 aria-hidden="true" />}
|
||||
</button>
|
||||
) : null}
|
||||
<div
|
||||
className="task-chat-transcript"
|
||||
ref={transcriptRef}
|
||||
@@ -628,19 +641,6 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
||||
aria-live="polite"
|
||||
data-testid="task-chat-transcript"
|
||||
>
|
||||
{onToggleExpanded ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm task-chat-expand-toggle task-chat-expand-toggle--overlay"
|
||||
onClick={onToggleExpanded}
|
||||
aria-label={expanded ? "Collapse chat" : "Expand chat to full modal"}
|
||||
aria-pressed={expanded}
|
||||
data-testid="task-chat-expand-toggle"
|
||||
>
|
||||
{expanded ? <Minimize2 aria-hidden="true" /> : <Maximize2 aria-hidden="true" />}
|
||||
<span>{expanded ? "Collapse" : "Expand"}</span>
|
||||
</button>
|
||||
) : null}
|
||||
{loading && transcriptItemCount === 0 ? (
|
||||
<div className="task-chat-empty" role="status">
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
|
||||
@@ -283,55 +283,64 @@ describe("TaskChatTab", () => {
|
||||
expect(screen.getByText(/No agent output yet/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the collapsed expand toggle inside the transcript and calls the toggle handler", () => {
|
||||
it("renders the collapsed icon-only expand toggle inside the chat view and calls the toggle handler", () => {
|
||||
const onToggleExpanded = vi.fn();
|
||||
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} expanded={false} onToggleExpanded={onToggleExpanded} />);
|
||||
|
||||
const toggle = screen.getByTestId("task-chat-expand-toggle");
|
||||
const transcript = screen.getByTestId("task-chat-transcript");
|
||||
expect(transcript).toContainElement(toggle);
|
||||
expect(screen.getByTestId("task-chat-tab")).toContainElement(toggle);
|
||||
expect(transcript).not.toContainElement(toggle);
|
||||
expect(document.querySelector(".task-chat-toolbar")).toBeNull();
|
||||
expect(toggle).toHaveClass("btn-icon");
|
||||
expect(toggle).toHaveClass("task-chat-expand-toggle--overlay");
|
||||
expect(toggle).toHaveAttribute("aria-label", "Expand chat to full modal");
|
||||
expect(toggle).toHaveAttribute("aria-pressed", "false");
|
||||
expect(toggle).toHaveTextContent("Expand");
|
||||
expect(toggle).not.toHaveTextContent("Expand");
|
||||
expect(toggle).not.toHaveTextContent("Collapse");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(onToggleExpanded).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the expanded collapse toggle", () => {
|
||||
it("renders the expanded icon-only collapse toggle", () => {
|
||||
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} expanded onToggleExpanded={vi.fn()} />);
|
||||
|
||||
const toggle = screen.getByTestId("task-chat-expand-toggle");
|
||||
expect(toggle).toHaveAttribute("aria-label", "Collapse chat");
|
||||
expect(toggle).toHaveAttribute("aria-pressed", "true");
|
||||
expect(toggle).toHaveTextContent("Collapse");
|
||||
expect(toggle).not.toHaveTextContent("Collapse");
|
||||
expect(toggle).not.toHaveTextContent("Expand");
|
||||
});
|
||||
|
||||
it("renders the expand toggle while the transcript is loading", () => {
|
||||
it("renders the icon-only expand toggle while the transcript is loading", () => {
|
||||
mockLogs([], true);
|
||||
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} onToggleExpanded={vi.fn()} />);
|
||||
|
||||
const toggle = screen.getByTestId("task-chat-expand-toggle");
|
||||
expect(screen.getByTestId("task-chat-transcript")).toContainElement(toggle);
|
||||
expect(screen.getByTestId("task-chat-tab")).toContainElement(toggle);
|
||||
expect(toggle).not.toHaveTextContent("Expand");
|
||||
expect(screen.getByText("Loading agent output…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the expand toggle in the empty transcript state", () => {
|
||||
it("renders the icon-only expand toggle in the empty transcript state", () => {
|
||||
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} onToggleExpanded={vi.fn()} />);
|
||||
|
||||
const toggle = screen.getByTestId("task-chat-expand-toggle");
|
||||
expect(screen.getByTestId("task-chat-transcript")).toContainElement(toggle);
|
||||
expect(screen.getByTestId("task-chat-tab")).toContainElement(toggle);
|
||||
expect(toggle).not.toHaveTextContent("Expand");
|
||||
expect(screen.getByText(/No agent output yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the expand toggle in the populated transcript state", () => {
|
||||
it("renders the icon-only expand toggle in the populated transcript state", () => {
|
||||
mockLogs([makeEntry({ agent: "executor", text: "executor output" })]);
|
||||
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} onToggleExpanded={vi.fn()} />);
|
||||
|
||||
const transcript = screen.getByTestId("task-chat-transcript");
|
||||
expect(transcript).toContainElement(screen.getByTestId("task-chat-expand-toggle"));
|
||||
const toggle = screen.getByTestId("task-chat-expand-toggle");
|
||||
expect(screen.getByTestId("task-chat-tab")).toContainElement(toggle);
|
||||
expect(transcript).not.toContainElement(toggle);
|
||||
expect(toggle).not.toHaveTextContent("Expand");
|
||||
expect(within(transcript).getByText("executor output")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -809,6 +818,24 @@ describe("TaskChatTab", () => {
|
||||
expect(screen.getByRole("button", { name: "Jump to latest message" })).toBe(jumpButton);
|
||||
});
|
||||
|
||||
it("keeps the icon-only expand toggle accessible after transcript scrolling", () => {
|
||||
const metrics = mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 });
|
||||
mockLogs([makeEntry({ agent: "executor", text: "scrollable output" })]);
|
||||
|
||||
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} expanded={false} onToggleExpanded={vi.fn()} />);
|
||||
const transcript = screen.getByTestId("task-chat-transcript");
|
||||
|
||||
metrics.scrollTop = 600;
|
||||
fireEvent.scroll(transcript);
|
||||
|
||||
const toggle = screen.getByTestId("task-chat-expand-toggle");
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(toggle).toBeVisible();
|
||||
expect(toggle).toHaveAccessibleName("Expand chat to full modal");
|
||||
expect(toggle).not.toHaveTextContent("Expand");
|
||||
expect(transcript).not.toContainElement(toggle);
|
||||
});
|
||||
|
||||
it("clicking the jump-to-bottom button snaps to the latest message and removes the control", async () => {
|
||||
const user = userEvent.setup();
|
||||
const metrics = mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 });
|
||||
@@ -1612,15 +1639,22 @@ describe("TaskChatTab", () => {
|
||||
expect(css).not.toContain("62vh");
|
||||
});
|
||||
|
||||
it("positions the expand toggle as a tokenized transcript overlay with no toolbar shell", () => {
|
||||
it("positions the icon-only expand toggle as a tokenized chat-view overlay with no toolbar shell", () => {
|
||||
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
||||
const tabRule = getCssRuleBlock(css, ".task-chat-tab");
|
||||
const transcriptRule = getCssRuleBlock(css, ".task-chat-transcript");
|
||||
const toggleRule = getCssRuleBlock(css, ".task-chat-expand-toggle");
|
||||
const overlayRule = getCssRuleBlock(css, ".task-chat-expand-toggle--overlay");
|
||||
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
|
||||
const mobileOverlayRule = getCssRuleBlock(mobileCss, ".task-chat-expand-toggle--overlay");
|
||||
|
||||
expect(css).not.toContain(".task-chat-toolbar");
|
||||
expect(tabRule).toContain("position: relative");
|
||||
expect(transcriptRule).toContain("position: relative");
|
||||
expect(toggleRule).toContain("justify-content: center");
|
||||
expect(toggleRule).toContain("min-inline-size: var(--space-2xl)");
|
||||
expect(toggleRule).toContain("min-block-size: var(--space-2xl)");
|
||||
expect(toggleRule).not.toContain("gap");
|
||||
expect(overlayRule).toContain("position: absolute");
|
||||
expect(overlayRule).toContain("top: var(--space-md)");
|
||||
expect(overlayRule).toContain("right: var(--space-md)");
|
||||
|
||||
Reference in New Issue
Block a user