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:
gsxdsm
2026-06-13 20:54:32 -07:00
parent 6bb120525d
commit 3d5c22c075
3 changed files with 68 additions and 26 deletions

View File

@@ -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);

View File

@@ -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" />

View File

@@ -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)");