FN-6370: add expandable task chat modal

Add a full-modal expansion affordance for task-detail chat conversations.

- Add an expand/collapse toolbar button to the task chat tab with accessible labels and icon states.
- Let the task detail modal switch into a chat-expanded layout and reset that state when leaving chat or entering edit mode.
- Cover the chat toggle and layout behavior with dashboard component tests and document the control.

Files changed:
 docs/dashboard-guide.md                            |   1 +
 packages/dashboard/app/components/TaskChatTab.css  |  22 +++++
 packages/dashboard/app/components/TaskChatTab.tsx  |  21 ++++-
 .../dashboard/app/components/TaskDetailModal.css   |  42 +++++++++
 .../dashboard/app/components/TaskDetailModal.tsx   |  13 ++-
 .../app/components/__tests__/TaskChatTab.test.tsx  |  37 ++++++++
 .../TaskDetailModal.attachments-and-tabs.test.tsx  | 100 +++++++++++++++++++++
 7 files changed, 233 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-6370
Fusion-Task-Lineage: 787300bb-b928-45cf-a5c1-7fed5f375111
This commit is contained in:
gsxdsm
2026-06-13 11:14:06 -07:00
parent 0d882b4350
commit 4838c86b8c
7 changed files with 233 additions and 3 deletions

View File

@@ -690,6 +690,7 @@ For related global/project configuration behavior, see [Settings reference](./se
Inspect task definition, logs, review feedback, comments, documents, workflow outcomes, model overrides, and task routing from a single modal.
- Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form.
- The **Chat** tab includes an expand/collapse control that lets the transcript and composer fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed.
- The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode.
- Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form.
- These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group.

View File

@@ -7,6 +7,19 @@
height: 100%;
}
.task-chat-toolbar {
display: flex;
flex: 0 0 auto;
justify-content: flex-end;
gap: var(--space-sm);
}
.task-chat-expand-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.task-chat-transcript {
display: flex;
flex: 1 1 auto;
@@ -325,6 +338,15 @@
gap: var(--space-sm);
}
.task-chat-toolbar {
justify-content: stretch;
}
.task-chat-expand-toggle {
justify-content: center;
width: 100%;
}
.task-chat-transcript {
flex: 1 1 auto;
min-height: 0;

View File

@@ -2,7 +2,7 @@ import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ChevronDown, Loader2, Send } from "lucide-react";
import { ChevronDown, Loader2, Maximize2, Minimize2, Send } from "lucide-react";
import { addSteeringComment } from "../api";
import { useAgentLogs } from "../hooks/useAgentLogs";
import type { ToastType } from "../hooks/useToast";
@@ -20,6 +20,8 @@ interface TaskChatTabProps {
addToast: (msg: string, type?: ToastType) => void;
sessionLive?: boolean;
onTaskUpdated?: (task: Task) => void;
expanded?: boolean;
onToggleExpanded?: () => void;
}
type AgentLogRole = AgentRole | undefined;
@@ -407,7 +409,7 @@ function TaskChatUserMessage({ message }: { message: UserChatMessage }) {
);
}
export function TaskChatTab({ task, projectId, active, addToast, sessionLive, onTaskUpdated }: TaskChatTabProps) {
export function TaskChatTab({ task, projectId, active, addToast, sessionLive, onTaskUpdated, expanded = false, onToggleExpanded }: TaskChatTabProps) {
const { entries, loading } = useAgentLogs(task.id, active, projectId);
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
@@ -601,6 +603,21 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
return (
<div className="task-chat-tab" data-testid="task-chat-tab">
{onToggleExpanded ? (
<div className="task-chat-toolbar">
<button
type="button"
className="btn btn-sm task-chat-expand-toggle"
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>
</div>
) : null}
<div
className="task-chat-transcript"
ref={transcriptRef}

View File

@@ -728,6 +728,36 @@
margin-top: var(--space-lg);
}
.task-detail-content--chat-expanded .detail-title-row {
display: none;
}
.task-detail-content--chat-expanded .detail-tabs {
display: none;
}
.task-detail-content--chat-expanded .modal-actions {
display: none;
}
.task-detail-content--chat-expanded .modal-header {
flex: 0 0 auto;
justify-content: flex-end;
padding-block: var(--space-sm);
}
.task-detail-content--chat-expanded .detail-body--chat {
flex: 1;
min-height: 0;
padding: var(--space-md);
}
.task-detail-content--chat-expanded .detail-section--chat {
flex: 1;
min-height: 0;
margin-top: 0;
}
.detail-spec-edit-trigger {
margin-bottom: var(--space-md);
@@ -954,6 +984,18 @@
flex: 1;
min-height: 0;
}
.task-detail-content--chat-expanded .detail-body--chat {
padding: var(--space-sm);
}
.task-detail-content--chat-expanded .detail-tabs {
display: none;
}
.task-detail-content--chat-expanded .modal-actions {
display: none;
}
}
.detail-actions-menu-item-danger {

View File

@@ -564,6 +564,7 @@ export function TaskDetailContent({
const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
const [activeTab, setActiveTab] = useState<TabId>(initialTab === "retries" ? "definition" : initialTab);
const [chatExpanded, setChatExpanded] = useState(false);
// ── CLI agent session (U11) ────────────────────────────────────────────────
const [cliSession, setCliSession] = useState<CliSessionSummaryRecord | null>(null);
@@ -777,6 +778,13 @@ export function TaskDetailContent({
// Edit mode state
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
if (activeTab !== "chat" || isEditing) {
setChatExpanded(false);
}
}, [activeTab, isEditing]);
const [editTitle, setEditTitle] = useState(task.title || "");
const [editDescription, setEditDescription] = useState(task.description || "");
const [editDependencies, setEditDependencies] = useState<string[]>(task.dependencies || []);
@@ -2625,6 +2633,7 @@ export function TaskDetailContent({
const autoMergeEnabled = autoMergeEnabledProp ?? (settings?.autoMerge ?? false);
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled });
const isManualPrFlow = mergeStrategy === "pull-request" && !autoMergeEnabled;
const isChatExpanded = chatExpanded && activeTab === "chat" && !isEditing;
const isCheckPrStatusAction = isManualPrFlow && !prAutomationLabel && task.prInfo?.status === "open";
let manualReviewActionLabel = t("taskDetail.pr.mergeAndClose", "Merge & Close");
@@ -2640,7 +2649,7 @@ export function TaskDetailContent({
return (
<div
className={embedded ? "task-detail-content task-detail-content--embedded" : "task-detail-content"}
className={`task-detail-content${embedded ? " task-detail-content--embedded" : ""}${isChatExpanded ? " task-detail-content--chat-expanded" : ""}`}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
@@ -3144,6 +3153,8 @@ export function TaskDetailContent({
addToast={addToast}
sessionLive={isCliSessionLive(cliSession)}
onTaskUpdated={handleChatTaskUpdated}
expanded={chatExpanded}
onToggleExpanded={() => setChatExpanded((value) => !value)}
/>
</div>
) : activeTab === "logs" ? (

View File

@@ -276,6 +276,43 @@ describe("TaskChatTab", () => {
expect(screen.getByText(/No agent output yet/)).toBeTruthy();
});
it("renders the collapsed expand toggle 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");
expect(toggle).toHaveAttribute("aria-label", "Expand chat to full modal");
expect(toggle).toHaveAttribute("aria-pressed", "false");
expect(toggle).toHaveTextContent("Expand");
fireEvent.click(toggle);
expect(onToggleExpanded).toHaveBeenCalledTimes(1);
});
it("renders the expanded 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");
});
it("renders the expand toggle while the transcript is loading", () => {
mockLogs([], true);
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} onToggleExpanded={vi.fn()} />);
expect(screen.getByTestId("task-chat-expand-toggle")).toBeInTheDocument();
expect(screen.getByText("Loading agent output…")).toBeInTheDocument();
});
it("renders the expand toggle in the empty transcript state", () => {
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} onToggleExpanded={vi.fn()} />);
expect(screen.getByTestId("task-chat-expand-toggle")).toBeInTheDocument();
expect(screen.getByText(/No agent output yet/)).toBeInTheDocument();
});
it("labels every agent role and the legacy undefined-agent fallback", () => {
mockLogs([
makeEntry({ agent: "triage", text: "planning output" }),

View File

@@ -787,6 +787,106 @@ describe("TaskDetailModal", () => {
expect(mobileSectionRule).toContain("min-height: 0");
});
it("FN-6370 defines expanded chat chrome-hiding CSS for desktop and mobile", () => {
const css = readDashboardStylesSource();
const expandedChromeRule = getCssRuleBlock(css, ".task-detail-content--chat-expanded .detail-title-row");
const expandedBodyRule = getCssRuleBlock(css, ".task-detail-content--chat-expanded .detail-body--chat");
const expandedSectionRule = getCssRuleBlock(css, ".task-detail-content--chat-expanded .detail-section--chat");
const mobileCss = css.slice(css.indexOf("@media (max-width: 768px)"));
const mobileTabsRule = getCssRuleBlock(mobileCss, ".task-detail-content--chat-expanded .detail-tabs");
expect(expandedChromeRule).toContain("display: none");
expect(expandedBodyRule).toContain("flex: 1");
expect(expandedBodyRule).toContain("min-height: 0");
expect(expandedSectionRule).toContain("margin-top: 0");
expect(mobileTabsRule).toContain("display: none");
});
it("FN-6370 expands and collapses chat without leaving chrome hidden", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Chat" }));
const content = container.querySelector(".task-detail-content");
expect(content).not.toHaveClass("task-detail-content--chat-expanded");
expect(container.querySelector(".detail-tabs")).toBeTruthy();
expect(container.querySelector(".modal-actions")).toBeTruthy();
fireEvent.click(screen.getByTestId("task-chat-expand-toggle"));
expect(content).toHaveClass("task-detail-content--chat-expanded");
expect(screen.getByTestId("task-chat-expand-toggle")).toHaveAttribute("aria-label", "Collapse chat");
expect(screen.getByTestId("task-chat-expand-toggle")).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByTestId("task-chat-expand-toggle"));
expect(content).not.toHaveClass("task-detail-content--chat-expanded");
expect(screen.getByTestId("task-chat-expand-toggle")).toHaveAttribute("aria-label", "Expand chat to full modal");
expect(screen.getByTestId("task-chat-expand-toggle")).toHaveAttribute("aria-pressed", "false");
});
it("FN-6370 resets expanded chat when the active tab changes", () => {
const { container, rerender } = render(
<TaskDetailContent
task={makeTask({ prompt: "# Hello\n\nContent" })}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
initialTab="chat"
/>,
);
const content = container.querySelector(".task-detail-content");
fireEvent.click(screen.getByTestId("task-chat-expand-toggle"));
expect(content).toHaveClass("task-detail-content--chat-expanded");
rerender(
<TaskDetailContent
task={makeTask({ prompt: "# Hello\n\nContent" })}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
initialTab="logs"
/>,
);
expect(container.querySelector(".task-detail-content--chat-expanded")).toBeNull();
expect(screen.queryByTestId("task-chat-expand-toggle")).toBeNull();
});
it("FN-6370 resets expanded chat when entering edit mode", () => {
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" }));
fireEvent.click(screen.getByTestId("task-chat-expand-toggle"));
expect(container.querySelector(".task-detail-content")).toHaveClass("task-detail-content--chat-expanded");
fireEvent.click(screen.getByLabelText("Edit task"));
expect(container.querySelector(".task-detail-content--chat-expanded")).toBeNull();
expect(screen.queryByTestId("task-chat-expand-toggle")).toBeNull();
});
it("FN-6347 applies chat modifiers only while the Chat tab is active", () => {
const { container } = render(
<TaskDetailModal