FN-6347: keep chat composer visible on mobile

Keep the task detail chat layout constrained so mobile users can always reach the composer.

- Give the chat tab a fill-height flex layout with transcript-only scrolling.
- Apply chat-specific modal body and section classes alongside the agent log layout.
- Cover mobile chat layout behavior with component tests and document the modal contract.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-6347-chat-input-visible.md           |  5 ++
 docs/dashboard-guide.md                            |  2 +-
 packages/dashboard/app/components/TaskChatTab.css  |  8 ++-
 .../dashboard/app/components/TaskDetailModal.css   | 29 +++++++++
 .../dashboard/app/components/TaskDetailModal.tsx   |  4 +-
 .../app/components/__tests__/TaskChatTab.test.tsx  | 36 ++++++++++
 .../TaskDetailModal.attachments-and-tabs.test.tsx  | 76 ++++++++++++++++++++++
 7 files changed, 155 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-6347

Fusion-Task-Lineage: 37250539-d38e-4826-9c31-8b0279844883
This commit is contained in:
gsxdsm
2026-06-13 03:32:59 -07:00
parent 06e1ee7565
commit 35554e674e
7 changed files with 155 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Keep the task-detail Chat composer pinned and visible while the transcript scrolls internally on mobile and desktop.

View File

@@ -728,7 +728,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
### Logs → Agent Log view
The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. For active, assigned, non-paused agent sessions in `in-progress` or `in-review` (reviewing/merging/fixing) tasks, the composer sends guidance to the running agent through the same steering path used by comments; this includes regular engine agents working in a task worktree as well as live CLI sessions. When no active session is available, the composer is disabled with an explanatory hint.
The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. For active, assigned, non-paused agent sessions in `in-progress` or `in-review` (reviewing/merging/fixing) tasks, the composer sends guidance to the running agent through the same steering path used by comments; this includes regular engine agents working in a task worktree as well as live CLI sessions. When no active session is available, the composer is disabled with an explanatory hint. The task-detail Chat tab keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally.
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:

View File

@@ -1,16 +1,18 @@
.task-chat-tab {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-md);
min-height: 0;
height: 100%;
}
.task-chat-transcript {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--space-lg);
min-height: 0;
max-height: var(--task-chat-transcript-max-height, 70vh);
overflow-y: auto;
padding: var(--space-md);
border: var(--btn-border-width) solid var(--border);
@@ -246,6 +248,7 @@
.task-chat-composer {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md);
@@ -282,7 +285,8 @@
}
.task-chat-transcript {
max-height: var(--task-chat-transcript-mobile-max-height, 62vh);
flex: 1 1 auto;
min-height: 0;
padding: var(--space-sm);
}

View File

@@ -94,6 +94,15 @@
overflow-y: hidden;
}
/* Chat mirrors the Agent Log fill-height layout: the modal body does not scroll;
the transcript owns internal scrolling while the composer stays visible. */
.detail-body--chat {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: hidden;
}
.detail-title {
font-size: 18px;
font-weight: 600;
@@ -711,6 +720,14 @@
margin-top: var(--space-lg);
}
.detail-section--chat {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
margin-top: var(--space-lg);
}
.detail-spec-edit-trigger {
margin-bottom: var(--space-md);
@@ -925,6 +942,18 @@
border-radius: 0;
resize: none;
}
.detail-body--chat {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: hidden;
}
.detail-section--chat {
flex: 1;
min-height: 0;
}
}
.detail-actions-menu-item-danger {

View File

@@ -2680,7 +2680,7 @@ export function TaskDetailContent({
)}
</div>
</div>
<div className={`detail-body${activeTab === "logs" && logSubview === "agent-log" && !isEditing ? " detail-body--agent-log" : ""}`}>
<div className={`detail-body${activeTab === "logs" && logSubview === "agent-log" && !isEditing ? " detail-body--agent-log" : ""}${activeTab === "chat" && !isEditing ? " detail-body--chat" : ""}`}>
{isEditing ? (
<div className="modal-edit-form">
<TaskForm
@@ -3136,7 +3136,7 @@ export function TaskDetailContent({
<ModelSelectorTab task={task} addToast={addToast} onTaskUpdated={onTaskUpdated} settings={settings} />
</div>
) : activeTab === "chat" ? (
<div className="detail-section">
<div className="detail-section detail-section--chat">
<TaskChatTab
task={workingTask}
projectId={projectId}

View File

@@ -81,6 +81,19 @@ function deferred<T>() {
return { promise, resolve, reject };
}
function getCssRuleBlock(css: string, selector: string): string {
const selectorIndex = css.indexOf(selector);
if (selectorIndex < 0) return "";
const ruleStart = css.indexOf("{", selectorIndex);
const ruleEnd = css.indexOf("}", ruleStart);
return ruleStart >= 0 && ruleEnd >= 0 ? css.slice(ruleStart + 1, ruleEnd) : "";
}
function getCssAfter(css: string, marker: string): string {
const markerIndex = css.indexOf(marker);
return markerIndex >= 0 ? css.slice(markerIndex) : "";
}
function mockLogs(entries: AgentLogEntry[] = [], loading = false) {
mockedUseAgentLogs.mockReturnValue({
entries,
@@ -1080,6 +1093,29 @@ describe("TaskChatTab", () => {
expect(screen.getByRole("button", { name: "Send" })).toHaveClass("task-chat-send");
});
it("FN-6347 pins the composer while the transcript flex-fills without fixed viewport caps", () => {
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
const tabRule = getCssRuleBlock(css, ".task-chat-tab");
const transcriptRule = getCssRuleBlock(css, ".task-chat-transcript");
const composerRule = getCssRuleBlock(css, ".task-chat-composer");
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
const mobileTranscriptRule = getCssRuleBlock(mobileCss, ".task-chat-transcript");
expect(tabRule).toContain("display: flex");
expect(tabRule).toContain("flex: 1");
expect(tabRule).toContain("min-height: 0");
expect(transcriptRule).toContain("flex: 1 1 auto");
expect(transcriptRule).toContain("min-height: 0");
expect(transcriptRule).toContain("overflow-y: auto");
expect(transcriptRule).not.toContain("max-height");
expect(composerRule).toContain("flex: 0 0 auto");
expect(mobileTranscriptRule).toContain("flex: 1 1 auto");
expect(mobileTranscriptRule).toContain("min-height: 0");
expect(mobileTranscriptRule).not.toContain("max-height");
expect(css).not.toContain("70vh");
expect(css).not.toContain("62vh");
});
it("keeps mobile breakpoint scaffolding for the transcript, composer, and collapsible groups", () => {
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
expect(css).toContain("@media (max-width: 768px)");

View File

@@ -764,6 +764,82 @@ describe("TaskDetailModal", () => {
});
});
describe("Chat full-height layout", () => {
it("FN-6347 defines chat modal-body and section fill-height CSS for desktop and mobile", () => {
const css = readDashboardStylesSource();
const bodyRule = getCssRuleBlock(css, ".detail-body--chat");
const sectionRule = getCssRuleBlock(css, ".detail-section--chat");
const mobileCss = css.slice(css.indexOf("@media (max-width: 768px)"));
const mobileBodyRule = getCssRuleBlock(mobileCss, ".detail-body--chat");
const mobileSectionRule = getCssRuleBlock(mobileCss, ".detail-section--chat");
expect(bodyRule).toContain("display: flex");
expect(bodyRule).toContain("flex-direction: column");
expect(bodyRule).toContain("min-height: 0");
expect(bodyRule).toContain("overflow-y: hidden");
expect(sectionRule).toContain("display: flex");
expect(sectionRule).toContain("flex-direction: column");
expect(sectionRule).toContain("flex: 1");
expect(sectionRule).toContain("min-height: 0");
expect(mobileBodyRule).toContain("overflow-y: hidden");
expect(mobileBodyRule).toContain("min-height: 0");
expect(mobileSectionRule).toContain("flex: 1");
expect(mobileSectionRule).toContain("min-height: 0");
});
it("FN-6347 applies chat modifiers only while the Chat tab is active", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(container.querySelector(".detail-body--chat")).toBeNull();
expect(container.querySelector(".detail-section--chat")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Chat" }));
const chatBody = container.querySelector(".detail-body--chat");
const chatSection = container.querySelector(".detail-section--chat");
expect(chatBody).toBeTruthy();
expect(chatBody).not.toHaveClass("detail-body--agent-log");
expect(chatSection).toBeTruthy();
expect(chatSection!.querySelector("[data-testid='task-chat-tab']")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Logs" }));
fireEvent.click(screen.getByText("Agent Log"));
expect(container.querySelector(".detail-body--chat")).toBeNull();
expect(container.querySelector(".detail-section--chat")).toBeNull();
expect(container.querySelector(".detail-body--agent-log")).toBeTruthy();
});
it("FN-6347 removes the chat body modifier while editing", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ column: "triage", prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Chat" }));
expect(container.querySelector(".detail-body--chat")).toBeTruthy();
fireEvent.click(screen.getByLabelText("Edit task"));
expect(container.querySelector(".detail-body--chat")).toBeNull();
expect(container.querySelector(".detail-section--chat")).toBeNull();
});
});
describe("Agent Log full-height layout", () => {
it("applies detail-body--agent-log class when Logs → Agent Log subview is active", () => {
const { container } = render(