fix(FN-7344): make planner chat expansion intentional

This commit is contained in:
gsxdsm
2026-06-30 23:51:16 -07:00
parent e37260b30c
commit 7c53c97137
9 changed files with 112 additions and 77 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make Planner Chat collapsed by default with a floating expand control that takes over the detail view.
category: fix
dev: Planner Chat expansion now mirrors Activity's overlay control and hides most task-detail chrome while preserving title/workflow context.

View File

@@ -883,6 +883,9 @@ Expanded Planner Chat must still expose task Priority, Execution Mode, and the t
FNXC:TaskDetailPlannerChat 2026-07-02-00:04:
Expanded Planner Chat must keep the task title and workflow badge visible on mobile. The title preserves task identity, and the workflow badge is owned by the timestamps row, so this mode hides lower-priority metadata but not `.detail-heading-row` or `.detail-timestamps`; timestamp labels inside that row can hide so the workflow badge remains compact.
FNXC:TaskDetailPlannerChat 2026-07-02-00:17:
Planner Chat should not open expanded by default. When the user expands it, the view should behave like Activity expansion: the chat panel takes nearly the whole modal, with only task title and workflow identity left above it for context.
*/
.detail-section--planner-chat {
display: flex;
@@ -895,6 +898,7 @@ Expanded Planner Chat must keep the task title and workflow badge visible on mob
.task-detail-content--planner-chat-expanded .detail-body--planner-chat {
flex: 1;
min-height: 0;
padding: var(--space-md);
}
.task-detail-content--planner-chat-expanded .detail-section--planner-chat {
@@ -902,6 +906,34 @@ Expanded Planner Chat must keep the task title and workflow badge visible on mob
min-height: 0;
}
.task-detail-content--planner-chat-expanded .detail-description-toggle,
.task-detail-content--planner-chat-expanded .detail-near-duplicate-banner,
.task-detail-content--planner-chat-expanded .task-fields-section,
.task-detail-content--planner-chat-expanded .branch-group-card,
.task-detail-content--planner-chat-expanded .workspace-worktrees-summary,
.task-detail-content--planner-chat-expanded .modal-actions,
.task-detail-content--planner-chat-expanded .detail-tabs,
.task-detail-content--planner-chat-expanded .detail-meta-inline-controls,
.task-detail-content--planner-chat-expanded .detail-provenance {
display: none;
}
.task-detail-content--planner-chat-expanded .modal-header {
flex: 0 0 auto;
justify-content: space-between;
padding-block: var(--space-sm);
}
.task-detail-content--planner-chat-expanded .detail-meta {
flex: 0 0 auto;
margin-bottom: var(--space-sm);
}
.task-detail-content--planner-chat-expanded .detail-timestamps .detail-timestamp-item,
.task-detail-content--planner-chat-expanded .detail-timestamps .detail-timestamp-separator {
display: none;
}
.detail-spec-edit-trigger {
display: flex;
@@ -1179,36 +1211,6 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a
display: none;
}
.task-detail-content--planner-chat-expanded .detail-description-toggle,
.task-detail-content--planner-chat-expanded .detail-near-duplicate-banner,
.task-detail-content--planner-chat-expanded .branch-group-card,
.task-detail-content--planner-chat-expanded .workspace-worktrees-summary,
.task-detail-content--planner-chat-expanded .modal-actions,
.task-detail-content--planner-chat-expanded .detail-provenance {
display: none;
}
.task-detail-content--planner-chat-expanded .modal-header {
flex: 0 0 auto;
justify-content: space-between;
padding-block: var(--space-sm);
}
.task-detail-content--planner-chat-expanded .detail-meta {
flex: 0 0 auto;
margin-bottom: var(--space-sm);
}
.task-detail-content--planner-chat-expanded .detail-timestamps .detail-timestamp-item,
.task-detail-content--planner-chat-expanded .detail-timestamps .detail-timestamp-separator {
display: none;
}
.task-detail-content--planner-chat-expanded .detail-tabs {
flex: 0 0 auto;
margin-bottom: var(--space-sm);
}
.detail-body--planner-chat {
padding: var(--space-sm);
}

View File

@@ -557,7 +557,7 @@ export function TaskDetailContent({
const [activeTab, setActiveTab] = useState<TabId>(() => resolveDefaultTab(initialTab, task.column, taskDetailChatFirst));
const [activitySegment, setActivitySegment] = useState<ActivitySegment>(() => resolveDefaultActivitySegment(initialTab));
const [activityExpanded, setActivityExpanded] = useState(false);
const [plannerChatExpanded, setPlannerChatExpanded] = useState(true);
const [plannerChatExpanded, setPlannerChatExpanded] = useState(false);
// ── CLI agent session (U11) ────────────────────────────────────────────────
const [cliSession, setCliSession] = useState<CliSessionSummaryRecord | null>(null);
@@ -708,7 +708,7 @@ export function TaskDetailContent({
// Reset description and planner-chat focus state when task changes
useEffect(() => {
setDescriptionExpanded(false);
setPlannerChatExpanded(true);
setPlannerChatExpanded(false);
}, [task.column, task.id]);
const [highlightStallCode, setHighlightStallCode] = useState<string | null>(null);

View File

@@ -1,4 +1,5 @@
.task-planner-chat {
position: relative;
display: flex;
flex: 1 1 auto;
flex-direction: column;
@@ -33,6 +34,20 @@
padding: 0;
}
/*
FNXC:TaskDetailPlannerChat 2026-07-02-00:16:
Planner Chat expansion should use the same in-view floating affordance as Activity Live/Feed, not a header action that shifts layout. Anchor it to the chat panel so it remains visible while the transcript scrolls.
*/
.task-planner-chat-expand-toggle--overlay {
position: absolute;
top: var(--space-md);
right: var(--space-md);
z-index: 3;
background: var(--surface);
border-color: var(--border);
box-shadow: var(--shadow-sm);
}
.task-planner-chat-header h4 {
margin: 0;
color: var(--text);
@@ -261,6 +276,7 @@ Mobile Planner Chat should match regular task chat: keep the composer as a singl
.task-planner-chat-header {
align-items: flex-start;
gap: var(--space-sm);
padding-right: calc(var(--space-2xl) + var(--space-lg));
}
.task-planner-chat-header-actions {
@@ -278,6 +294,11 @@ Mobile Planner Chat should match regular task chat: keep the composer as a singl
min-block-size: calc(var(--space-2xl) + var(--space-sm));
}
.task-planner-chat-expand-toggle--overlay {
top: var(--space-sm);
right: var(--space-sm);
}
.task-planner-chat-composer {
flex-direction: row;
flex-wrap: nowrap;

View File

@@ -500,6 +500,19 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
*/
return (
<section className="task-planner-chat" aria-label={t("taskDetail.plannerChat.label", "Planner chat")} data-testid="task-planner-chat-panel">
{onExpandedChange && (
<button
type="button"
className="btn btn-icon btn-sm task-planner-chat-expand-toggle task-planner-chat-expand-toggle--overlay"
onClick={() => onExpandedChange(!expanded)}
aria-label={expanded ? t("taskDetail.plannerChat.collapse", "Collapse planner chat") : t("taskDetail.plannerChat.expand", "Expand planner chat")}
aria-pressed={expanded}
aria-expanded={expanded}
data-testid="task-planner-chat-expand-toggle"
>
{expanded ? <Minimize2 aria-hidden="true" /> : <Maximize2 aria-hidden="true" />}
</button>
)}
<div className="task-planner-chat-header">
<div>
<h4>{t("taskDetail.plannerChat.heading", "Planner Chat")}</h4>
@@ -515,19 +528,6 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
<ProviderIcon provider={planningModel.provider} size="md" />
</span>
)}
{onExpandedChange && (
<button
type="button"
className="btn btn-icon btn-sm task-planner-chat-expand-toggle"
onClick={() => onExpandedChange(!expanded)}
aria-label={expanded ? t("taskDetail.plannerChat.collapse", "Collapse planner chat") : t("taskDetail.plannerChat.expand", "Expand planner chat")}
aria-pressed={expanded}
aria-expanded={expanded}
data-testid="task-planner-chat-expand-toggle"
>
{expanded ? <Minimize2 aria-hidden="true" /> : <Maximize2 aria-hidden="true" />}
</button>
)}
</div>
</div>

View File

@@ -477,8 +477,8 @@ describe("TaskDetailModal", () => {
expect(screen.queryByRole("button", { name: "Logs" })).toBeNull();
expect(screen.getByRole("button", { name: "Chat" })).toHaveClass("detail-tab-active");
expect(screen.getByTestId("task-planner-chat-panel")).toBeTruthy();
expect(screen.getByTestId("task-planner-chat-expand-toggle")).toHaveAttribute("aria-label", "Collapse planner chat");
expect(container.querySelector(".task-detail-content")).toHaveClass("task-detail-content--planner-chat-expanded");
expect(screen.getByTestId("task-planner-chat-expand-toggle")).toHaveAttribute("aria-label", "Expand planner chat");
expect(container.querySelector(".task-detail-content")).not.toHaveClass("task-detail-content--planner-chat-expanded");
expect(container.querySelector(".activity-segmented-control")).toBeNull();
expect(container.querySelector(".activity-segment")).toBeNull();
expect(screen.queryByTestId("task-chat-expand-toggle")).toBeNull();
@@ -947,7 +947,7 @@ describe("TaskDetailModal", () => {
);
const content = container.querySelector(".task-detail-content");
expect(screen.getByTestId("task-planner-chat-expand-toggle")).toHaveAttribute("aria-label", "Collapse planner chat");
expect(screen.getByTestId("task-planner-chat-expand-toggle")).toHaveAttribute("aria-label", "Expand planner chat");
expect(screen.queryByTestId("task-chat-expand-toggle")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Activity" }));

View File

@@ -107,6 +107,7 @@ describe("TaskDetailModal", () => {
const headerBlock = getExactCssRuleBlock(css, ".task-planner-chat-header");
const headerActionsBlock = getExactCssRuleBlock(css, ".task-planner-chat-header-actions");
const modelBlock = getExactCssRuleBlock(css, ".task-planner-chat-model");
const expandOverlayBlock = getExactCssRuleBlock(css, ".task-planner-chat-expand-toggle--overlay");
const composerBlock = getExactCssRuleBlock(css, ".task-planner-chat-composer");
const inputBlock = getExactCssRuleBlock(css, ".task-planner-chat-input");
const mobileBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 768px)", ".task-planner-chat-composer");
@@ -115,9 +116,13 @@ describe("TaskDetailModal", () => {
const mobileSendBlock = getCssRuleBlock(mobileBlock, ".task-planner-chat-send");
expectBaseRule(css, ".task-planner-chat", "display: flex;");
expectBaseRule(css, ".task-planner-chat", "position: relative;");
expectBaseRule(css, ".task-planner-chat", "min-height: 0;");
expectBaseRule(css, ".task-planner-chat-transcript", "overflow: auto;");
expectBaseRule(css, ".task-planner-chat-transcript", "min-height: 0;");
expect(expandOverlayBlock).toContain("position: absolute;");
expect(expandOverlayBlock).toContain("top: var(--space-md);");
expect(expandOverlayBlock).toContain("right: var(--space-md);");
expect(headerBlock).toContain("justify-content: space-between;");
expect(headerActionsBlock).toContain("justify-content: flex-end;");
expect(headerActionsBlock).not.toContain("flex-wrap: wrap;");
@@ -150,21 +155,19 @@ describe("TaskDetailModal", () => {
expect(mobileBlock).toContain("margin-inline: 0;");
const detailCss = readDashboardStylesSource();
const plannerMobileBlock = getCssAtRuleBlockContaining(detailCss, "@media (max-width: 768px)", ".task-detail-content--planner-chat-expanded .detail-meta");
const plannerExpandedTabsBlock = getCssRuleBlock(plannerMobileBlock, ".task-detail-content--planner-chat-expanded .detail-tabs");
const plannerExpandedMetaBlock = getCssRuleBlock(plannerMobileBlock, ".task-detail-content--planner-chat-expanded .detail-meta");
const plannerExpandedMetaBlock = getExactCssRuleBlock(detailCss, ".task-detail-content--planner-chat-expanded .detail-meta");
expectBaseRule(detailCss, ".detail-body--planner-chat", "overflow-y: hidden;");
expectBaseRule(detailCss, ".detail-section--planner-chat", "min-height: 0;");
expect(plannerMobileBlock).toContain(".task-detail-content--planner-chat-expanded .detail-provenance");
expect(plannerMobileBlock).toContain(".task-detail-content--planner-chat-expanded .modal-actions");
expect(plannerMobileBlock).toContain("display: none;");
expect(plannerMobileBlock).not.toContain(".task-detail-content--planner-chat-expanded .detail-heading-row");
expect(plannerMobileBlock).not.toMatch(/\.task-detail-content--planner-chat-expanded \.detail-timestamps\s*\{/);
expect(plannerMobileBlock).toContain(".task-detail-content--planner-chat-expanded .detail-timestamps .detail-timestamp-item");
expect(plannerMobileBlock).not.toMatch(/\.task-detail-content--planner-chat-expanded \.detail-meta,\s*\.task-detail-content--planner-chat-expanded \.detail-near-duplicate-banner/);
expect(plannerMobileBlock).not.toMatch(/\.task-detail-content--planner-chat-expanded \.detail-tabs,\s*\.task-detail-content--planner-chat-expanded \.branch-group-card/);
expect(detailCss).toContain(".task-detail-content--planner-chat-expanded .detail-provenance");
expect(detailCss).toContain(".task-detail-content--planner-chat-expanded .modal-actions");
expect(detailCss).toContain(".task-detail-content--planner-chat-expanded .detail-tabs");
expect(detailCss).toContain(".task-detail-content--planner-chat-expanded .detail-meta-inline-controls");
expect(detailCss).not.toContain(".task-detail-content--planner-chat-expanded .detail-heading-row");
expect(detailCss).not.toMatch(/\.task-detail-content--planner-chat-expanded \.detail-timestamps\s*\{/);
expect(detailCss).toContain(".task-detail-content--planner-chat-expanded .detail-timestamps .detail-timestamp-item");
expect(detailCss).not.toMatch(/\.task-detail-content--planner-chat-expanded \.detail-meta,\s*\.task-detail-content--planner-chat-expanded \.detail-near-duplicate-banner/);
expect(detailCss).not.toMatch(/\.task-detail-content--planner-chat-expanded \.detail-tabs,\s*\.task-detail-content--planner-chat-expanded \.branch-group-card/);
expect(plannerExpandedMetaBlock).toContain("flex: 0 0 auto;");
expect(plannerExpandedTabsBlock).toContain("flex: 0 0 auto;");
});
it("keeps Planner Chat expand spacing identical across desktop and mobile", () => {
@@ -178,14 +181,14 @@ describe("TaskDetailModal", () => {
const plannerComposerBlock = getExactCssRuleBlock(css, ".task-planner-chat-composer");
const expandedPlannerBodyBlock = getExactCssRuleBlock(css, ".task-detail-content--planner-chat-expanded .detail-body--planner-chat");
const expandedPlannerSectionBlock = getExactCssRuleBlock(css, ".task-detail-content--planner-chat-expanded .detail-section--planner-chat");
const mobilePlannerBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 768px)", ".task-detail-content--planner-chat-expanded .detail-meta");
const mobilePlannerBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 768px)", ".detail-body--planner-chat");
const mobilePlannerBodyBlock = getStandaloneCssRuleBlock(mobilePlannerBlock, ".detail-body--planner-chat");
const mobileExpandedPlannerBodyBlock = getExactCssRuleBlock(mobilePlannerBlock, ".task-detail-content--planner-chat-expanded .detail-body--planner-chat");
expect(plannerBodyBlock).toContain("padding: var(--space-md);");
expect(expandedPlannerBodyBlock).toContain("flex: 1;");
expect(expandedPlannerBodyBlock).toContain("min-height: 0;");
expectNoSpacingOverrides(expandedPlannerBodyBlock, "desktop expanded planner body");
expect(expandedPlannerBodyBlock).toContain("padding: var(--space-md);");
expect(expandedPlannerSectionBlock).toContain("flex: 1;");
expect(expandedPlannerSectionBlock).toContain("min-height: 0;");
expectNoSpacingOverrides(expandedPlannerSectionBlock, "desktop expanded planner section");
@@ -196,7 +199,7 @@ describe("TaskDetailModal", () => {
expect(plannerTranscriptBlock).toContain("gap: var(--space-md);");
expect(plannerComposerBlock).toContain("gap: var(--space-sm);");
expect(css).not.toMatch(/task-detail-content--planner-chat-expanded[^{]+\.(?:task-planner-chat|task-planner-chat-transcript|task-planner-chat-composer)\s*\{[^}]*(?:padding|margin|gap)\s*:/);
expect(css).not.toMatch(/task-detail-content--planner-chat-expanded[^{]+\.detail-body--planner-chat\s*\{[^}]*(?:padding|margin|gap)\s*:/);
expect(css).not.toMatch(/task-detail-content--planner-chat-expanded[^{]+\.detail-body--planner-chat\s*\{[^}]*(?:margin|gap)\s*:/);
});
it("keeps detail metadata as a single wrapping flex row without mobile column fallbacks", () => {

View File

@@ -137,25 +137,25 @@ describe("TaskDetailModal planner Chat tab", () => {
expect(screen.getByTestId("task-planner-chat-panel")).toBeInTheDocument();
});
it("defaults planner Chat to focused mode and lets the in-view control collapse it", async () => {
it("defaults planner Chat to collapsed mode and lets the in-view control expand it", async () => {
const user = userEvent.setup();
const { container } = renderTask("todo");
const detail = container.querySelector(".task-detail-content");
expect(detail).toHaveClass("task-detail-content--planner-chat-expanded");
const toggle = screen.getByTestId("task-planner-chat-expand-toggle");
expect(toggle).toHaveAccessibleName("Collapse planner chat");
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(detail).not.toHaveClass("task-detail-content--planner-chat-expanded");
expect(toggle).toHaveAccessibleName("Expand planner chat");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Activity" })).toBeInTheDocument();
await user.click(toggle);
expect(detail).not.toHaveClass("task-detail-content--planner-chat-expanded");
expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Activity" })).toBeInTheDocument();
expect(screen.getByTestId("task-planner-chat-expand-toggle")).toHaveAccessibleName("Expand planner chat");
expect(detail).toHaveClass("task-detail-content--planner-chat-expanded");
expect(screen.getByTestId("task-planner-chat-expand-toggle")).toHaveAccessibleName("Collapse planner chat");
});
it("resets planner Chat focused mode when switching tasks", async () => {
it("resets planner Chat expanded mode when switching tasks", async () => {
const user = userEvent.setup();
const { container, rerender } = render(
<TaskDetailModal
@@ -172,7 +172,7 @@ describe("TaskDetailModal planner Chat tab", () => {
const detail = container.querySelector(".task-detail-content");
await user.click(screen.getByTestId("task-planner-chat-expand-toggle"));
expect(detail).not.toHaveClass("task-detail-content--planner-chat-expanded");
expect(detail).toHaveClass("task-detail-content--planner-chat-expanded");
rerender(
<TaskDetailModal
@@ -187,7 +187,7 @@ describe("TaskDetailModal planner Chat tab", () => {
/>,
);
expect(detail).toHaveClass("task-detail-content--planner-chat-expanded");
expect(detail).not.toHaveClass("task-detail-content--planner-chat-expanded");
});
it("keeps Activity expansion independent from planner Chat expansion", async () => {
@@ -201,11 +201,11 @@ describe("TaskDetailModal planner Chat tab", () => {
const chatTab = container.querySelectorAll<HTMLButtonElement>(".detail-tabs .detail-tab")[0];
expect(chatTab?.textContent?.trim()).toBe("Chat");
fireEvent.click(chatTab!);
expect(detail).toHaveClass("task-detail-content--planner-chat-expanded");
expect(detail).not.toHaveClass("task-detail-content--planner-chat-expanded");
expect(detail).not.toHaveClass("task-detail-content--chat-expanded");
await user.click(screen.getByTestId("task-planner-chat-expand-toggle"));
expect(detail).not.toHaveClass("task-detail-content--planner-chat-expanded");
expect(detail).toHaveClass("task-detail-content--planner-chat-expanded");
expect(detail).not.toHaveClass("task-detail-content--chat-expanded");
});
});

View File

@@ -182,7 +182,9 @@ describe("TaskPlannerChatTab", () => {
const modelBadge = screen.getByTestId("task-planner-chat-model");
expect(toggle).toHaveAccessibleName("Collapse planner chat");
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(modelBadge.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(toggle).toHaveClass("task-planner-chat-expand-toggle--overlay");
expect(screen.getByTestId("task-planner-chat-panel")).toContainElement(toggle);
expect(modelBadge).toBeInTheDocument();
expect(screen.getByTestId("task-planner-chat-panel")).toContainElement(screen.getByLabelText("Message planner chat"));
expect(screen.getByTestId("task-planner-chat-panel")).toContainElement(screen.getByRole("button", { name: "Send" }));