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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user