FN-6965: stack list-pane chat agent headers

Stack List View task-chat agent headers above their output in the split detail pane while keeping desktop chat layouts unchanged.

- Add a split-pane-specific CSS override that stacks task chat groups in List View only.
- Keep compact header sizing safe without affecting modal, board, expanded, or popped-out chat layouts.
- Cover List View populated, loading, empty, and CSS host-scope behavior in TaskChatTab tests.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-6965-list-pane-chat-layout.md        |  5 ++
 packages/dashboard/app/components/TaskChatTab.css  | 12 +++
 .../app/components/__tests__/TaskChatTab.test.tsx  | 90 ++++++++++++++++++++++
 3 files changed, 107 insertions(+)

Fusion-Task-Id: FN-6965

Fusion-Task-Lineage: 8107ae8c-c25e-497e-8506-4c77e3e083d3
This commit is contained in:
gsxdsm
2026-06-23 21:57:53 -07:00
parent 1f0a48582d
commit b9821eebd7
3 changed files with 107 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Stack task-detail Chat agent headers above output blocks in the List View split-pane detail pane while preserving full-width desktop chat layout.

View File

@@ -166,6 +166,18 @@ FN-6425 requires the chat expand control to stay inside the chat view as an icon
white-space: nowrap; white-space: nowrap;
} }
/*
FNXC:TaskDetailChat 2026-06-23-21:38:
List View split-pane detail can be narrow while the global viewport is desktop-sized. Scope the compact/mobile agent-output group contract to that host so agent/provider headers stack above output blocks there without changing full-width modal, board main-panel, popped-out, or expanded chat desktop layouts.
*/
.list-split-detail-content .task-chat-group {
grid-template-columns: 1fr;
}
.list-split-detail-content .task-chat-group-header {
min-width: 0;
}
.task-chat-group-bubbles { .task-chat-group-bubbles {
display: flex; display: flex;
min-width: 0; min-width: 0;

View File

@@ -167,6 +167,22 @@ function expectTranscriptTextOrder(...texts: string[]) {
} }
} }
function expectAgentHeaderBeforeBubbles(group: HTMLElement) {
const header = group.querySelector(".task-chat-group-header");
const bubbles = group.querySelector(".task-chat-group-bubbles");
expect(header).not.toBeNull();
expect(bubbles).not.toBeNull();
expect(header?.compareDocumentPosition(bubbles as Element) ?? 0).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
}
function renderListSplitTaskChat(task: Task = makeTask()) {
return render(
<div className="list-split-detail-content" data-testid="list-split-detail-content">
<TaskChatTab task={task} active addToast={vi.fn()} />
</div>,
);
}
function expectIdleSessionHint() { function expectIdleSessionHint() {
// FNXC:TaskDetailChat 2026-06-22-21:20: The idle "No agent is working…" banner was removed per user request — idle chats stay sendable with no hint shown. // FNXC:TaskDetailChat 2026-06-22-21:20: The idle "No agent is working…" banner was removed per user request — idle chats stay sendable with no hint shown.
expect(screen.queryByTestId("task-chat-idle-hint")).not.toBeInTheDocument(); expect(screen.queryByTestId("task-chat-idle-hint")).not.toBeInTheDocument();
@@ -486,6 +502,55 @@ describe("TaskChatTab", () => {
expect(screen.queryByLabelText("Executor: model provider unknown")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Executor: model provider unknown")).not.toBeInTheDocument();
}); });
it("keeps List View split chat agent headers before text tool thinking and user output", () => {
mockLogs([
makeEntry({ agent: "executor", text: "executor compact text", timestamp: "2026-06-12T00:00:00.000Z" }),
makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test", timestamp: "2026-06-12T00:00:01.000Z" }),
makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok", timestamp: "2026-06-12T00:00:02.000Z" }),
makeEntry({ agent: "executor", type: "thinking", text: "checking compact layout", timestamp: "2026-06-12T00:00:03.000Z" }),
makeEntry({ text: "fallback compact text", timestamp: "2026-06-12T00:00:05.000Z" }),
]);
renderListSplitTaskChat(
makeTask({
modelProvider: "openai",
modelId: "gpt-4o",
steeringComments: [makeSteeringComment({ id: "compact-user", text: "compact user guidance", createdAt: "2026-06-12T00:00:04.000Z" })],
}),
);
const host = screen.getByTestId("list-split-detail-content");
const executorGroup = within(host).getByLabelText("Executor messages");
const fallbackGroup = within(host).getByLabelText("Agent messages");
expectAgentHeaderBeforeBubbles(executorGroup);
expectAgentHeaderBeforeBubbles(fallbackGroup);
expect(within(executorGroup).getAllByText("Executor")).toHaveLength(1);
expect(within(fallbackGroup).getAllByText("Agent")).toHaveLength(1);
expect(within(executorGroup).getByText("executor compact text")).toBeVisible();
expect(within(executorGroup).getByTestId("task-chat-tool-group")).toBeInTheDocument();
expect(within(executorGroup).getByTestId("task-chat-thinking")).toBeInTheDocument();
expect(within(host).getByText("compact user guidance").closest(".task-chat-user-group")).not.toBeNull();
expect(within(host).getByText("fallback compact text")).toBeVisible();
expect(document.querySelector(".task-chat-provider-icon [data-provider='openai']")).toBeTruthy();
expect(within(fallbackGroup).getByLabelText("Agent: model provider unknown")).toBeVisible();
});
it("keeps List View split chat empty and loading states free of header shells", () => {
mockLogs([], true);
const loading = renderListSplitTaskChat();
expect(screen.getByText(/Loading agent output/)).toBeVisible();
expect(document.querySelector(".task-chat-group-header")).not.toBeInTheDocument();
expect(document.querySelector(".task-chat-group-bubbles")).not.toBeInTheDocument();
loading.unmount();
mockLogs([]);
renderListSplitTaskChat();
expect(screen.getByText(/No agent output yet/)).toBeVisible();
expect(document.querySelector(".task-chat-group-header")).not.toBeInTheDocument();
expect(document.querySelector(".task-chat-group-bubbles")).not.toBeInTheDocument();
});
it("groups consecutive entries by agent role", () => { it("groups consecutive entries by agent role", () => {
mockLogs([ mockLogs([
makeEntry({ agent: "executor", text: "first" }), makeEntry({ agent: "executor", text: "first" }),
@@ -2413,6 +2478,31 @@ describe("TaskChatTab", () => {
expect(source).toContain("expanded={chatExpanded}"); expect(source).toContain("expanded={chatExpanded}");
}); });
it("stacks agent headers only inside the List View split-detail chat host", () => {
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
const desktopGroupRule = getCssRuleBlock(css, ".task-chat-group");
const listSplitGroupRule = getCssRuleBlock(css, ".list-split-detail-content .task-chat-group");
const listSplitHeaderRule = getCssRuleBlock(css, ".list-split-detail-content .task-chat-group-header");
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
const mobileGroupRule = getCssRuleBlock(mobileCss, ".task-chat-group");
expect(desktopGroupRule).toContain("grid-template-columns: auto minmax(0, 1fr)");
expect(listSplitGroupRule).toContain("grid-template-columns: 1fr");
expect(listSplitHeaderRule).toContain("min-width: 0");
expect(mobileGroupRule).toContain("grid-template-columns: 1fr");
});
it("keeps List View as the only split-pane host for compact task chat", () => {
const listSource = readFileSync(resolve(__dirname, "../ListView.tsx"), "utf8");
const appSource = readFileSync(resolve(__dirname, "../../App.tsx"), "utf8");
expect(listSource).toContain('className="list-split-detail-content"');
expect(listSource).toContain("<TaskDetailContent");
expect(listSource).toContain("embedded");
expect(appSource).toContain('className="task-detail-main-panel-body"');
expect(appSource).toContain("<TaskDetailContent");
});
it("keeps task chat timestamp styling tokenized and mobile-safe", () => { it("keeps task chat timestamp styling tokenized and mobile-safe", () => {
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8"); const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
const groupMetaRule = getCssRuleBlock(css, ".task-chat-group-meta"); const groupMetaRule = getCssRuleBlock(css, ".task-chat-group-meta");