FN-7351: Make Activity tab switch views

Make the Activity tab act as the Live, Feed, and Raw view picker in task details.

- Add an Activity tab dropdown with keyboard, outside-click, and Escape handling.
- Remove the duplicate in-panel Activity view selector while preserving legacy segment routing.
- Keep Activity view switching available in expanded mode and update responsive styling/tests.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-7351-activity-tab-dropdown.md        |   7 +
 .../dashboard/app/components/TaskDetailModal.css   |  84 +++++++++---
 .../dashboard/app/components/TaskDetailModal.tsx   | 148 +++++++++++++++------
 .../TaskDetailModal.attachments-and-tabs.test.tsx  |  49 +++++--
 .../__tests__/TaskDetailModal.css.test.ts          |   8 +-
 ...lModal.inline-editing-and-integrations.test.tsx |   2 +-
 ...skDetailModal.models-progress-workflow.test.tsx |  15 ++-
 .../__tests__/TaskDetailModal.rendering.test.tsx   |   2 +-
 ...etailModal.responsive-and-dependencies.test.tsx |  29 ++--
 .../TaskDetailModal.task-activity-chat.test.tsx    |  53 ++++++--
 .../components/__tests__/TaskDetailModal.test.tsx  |  30 ++++-
 11 files changed, 318 insertions(+), 109 deletions(-)

Fusion-Task-Id: FN-7351
Fusion-Task-Lineage: 7b544505-953f-4c77-a92f-eadb2d9477f3
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 01:29:14 -07:00
parent b3b01bc25d
commit 72eb9af16c
11 changed files with 317 additions and 108 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make the task Activity tab switch Live, Feed, and Raw views directly.
category: fix
dev: Removes the duplicate in-panel Activity view select from TaskDetailModal.

View File

@@ -836,7 +836,8 @@ The same expanded-mode chrome collapse now applies to all Activity segments: Liv
} }
.task-detail-content--chat-expanded .detail-tabs { .task-detail-content--chat-expanded .detail-tabs {
display: none; /* FNXC:TaskDetailActivity 2026-06-30-23:59: Expanded Activity still needs the top-level Activity tab dropdown because the in-panel view selector was removed. Keep the tab strip visible so Live, Feed, and Raw remain switchable while the footer and metadata chrome stay collapsed. */
display: flex;
} }
.task-detail-content--chat-expanded .modal-actions { .task-detail-content--chat-expanded .modal-actions {
@@ -1204,7 +1205,7 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a
} }
.task-detail-content--chat-expanded .detail-tabs { .task-detail-content--chat-expanded .detail-tabs {
display: none; display: flex;
} }
.task-detail-content--chat-expanded .modal-actions { .task-detail-content--chat-expanded .modal-actions {
@@ -2500,26 +2501,76 @@ FNXC:TaskDetailTabs 2026-06-26-00:35:
/* === Activity View Selector === */ /* === Activity View Selector === */
/* /*
FNXC:TaskDetailActivity 2026-06-30-15:50: FNXC:TaskDetailActivity 2026-06-30-23:59:
The Activity top-level tab owns Live (internal `current`), Feed, and Raw as one dropdown rather than in-content subtabs. Keep the selector compact and token-styled on desktop/mobile, with the Activity-wide expand control beside it on every view. The Activity tab itself is the Live/Feed/Raw dropdown trigger. Keep it in the `.detail-tabs` scroller with the same tab sizing and active underline, and keep the Activity panel toolbar only for the expand affordance so no duplicate in-content selector or empty mobile shell remains.
*/ */
.detail-tab-dropdown {
position: relative;
flex-shrink: 0;
}
.detail-tab--activity {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.detail-tab-chevron {
inline-size: var(--icon-size-sm);
block-size: var(--icon-size-sm);
flex: 0 0 auto;
}
.activity-view-menu {
position: absolute;
inset-block-start: 100%;
inset-inline-start: 0;
z-index: 20;
min-inline-size: 100%;
margin-block-start: var(--space-xs);
padding: var(--space-xs);
display: flex;
flex-direction: column;
gap: calc(var(--space-xs) / 2);
background: var(--card);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.activity-view-menu-item {
appearance: none;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
font: inherit;
text-align: start;
padding: var(--space-xs) var(--space-sm);
white-space: nowrap;
}
.activity-view-menu-item:hover,
.activity-view-menu-item:focus-visible,
.activity-view-menu-item[aria-current="true"] {
background: var(--surface-hover);
outline: none;
}
.activity-view-menu-item[aria-current="true"] {
color: var(--text);
font-weight: 600;
}
.activity-toolbar { .activity-toolbar {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: flex-end;
gap: var(--space-sm); gap: var(--space-sm);
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
} }
.activity-view-select {
min-inline-size: calc(var(--space-2xl) + var(--space-xl) + var(--space-lg));
min-block-size: var(--space-2xl);
padding-block: calc(var(--space-xs) / 2);
padding-inline: var(--space-sm) var(--space-xl);
font-size: var(--font-size-sm);
line-height: var(--line-height-tight);
}
.activity-expand-toggle { .activity-expand-toggle {
flex: 0 0 auto; flex: 0 0 auto;
min-inline-size: var(--space-2xl); min-inline-size: var(--space-2xl);
@@ -2544,9 +2595,8 @@ The Activity top-level tab owns Live (internal `current`), Feed, and Raw as one
align-items: stretch; align-items: stretch;
} }
.activity-view-select { .activity-view-menu {
flex: 1 1 auto; min-inline-size: calc(100% + var(--space-xl));
min-inline-size: 0;
} }
.activity-expand-toggle { .activity-expand-toggle {

View File

@@ -1,5 +1,5 @@
import "./TaskDetailModal.css"; import "./TaskDetailModal.css";
import React, { Suspense, lazy, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import React, { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2 } from "lucide-react"; import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2 } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useModalResizePersist } from "../hooks/useModalResizePersist";
@@ -215,8 +215,8 @@ The first Activity segment keeps the stable internal `current` id for legacy seg
FNXC:TaskDetailActivity 2026-06-30-23:55: FNXC:TaskDetailActivity 2026-06-30-23:55:
The first Activity segment is user-facing Live while legacy internals remain `current` and explicit `initialTab="chat"` continues landing there for compatibility. The first Activity segment is user-facing Live while legacy internals remain `current` and explicit `initialTab="chat"` continues landing there for compatibility.
FNXC:TaskDetailActivity 2026-06-30-15:50: FNXC:TaskDetailActivity 2026-06-30-23:59:
Activity view switching uses one dropdown labeled for Live, Feed, and Raw while retaining the internal `current`, `feed`, and `raw-logs` segment ids. Legacy `chat` and `logs` initial-tab routing remains compatible so older links still open Activity → Live or Activity → Feed. Activity view switching lives in the top-level Activity tab dropdown for Live, Feed, and Raw while retaining the internal `current`, `feed`, and `raw-logs` segment ids. Legacy `chat` and `logs` initial-tab routing remains compatible so older links still open Activity → Live or Activity → Feed.
*/ */
function resolveDefaultTab(initialTab: TabId | undefined, column: ColumnId, taskDetailChatFirst = false): TabId { function resolveDefaultTab(initialTab: TabId | undefined, column: ColumnId, taskDetailChatFirst = false): TabId {
if (initialTab === "retries") { if (initialTab === "retries") {
@@ -555,7 +555,6 @@ export function TaskDetailContent({
workflowFieldDefs: workflowFieldDefsProp, workflowFieldDefs: workflowFieldDefsProp,
}: TaskDetailContentProps) { }: TaskDetailContentProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const activitySelectorId = useId();
const columnLabel = useColumnLabel(); const columnLabel = useColumnLabel();
const fileBrowser = useFileBrowser(); const fileBrowser = useFileBrowser();
const [activeTab, setActiveTab] = useState<TabId>(() => resolveDefaultTab(initialTab, task.column, taskDetailChatFirst)); const [activeTab, setActiveTab] = useState<TabId>(() => resolveDefaultTab(initialTab, task.column, taskDetailChatFirst));
@@ -922,6 +921,7 @@ export function TaskDetailContent({
// Split-menu dropdown state for footer actions // Split-menu dropdown state for footer actions
const [showMoveMenu, setShowMoveMenu] = useState(false); const [showMoveMenu, setShowMoveMenu] = useState(false);
const [showActionsMenu, setShowActionsMenu] = useState(false); const [showActionsMenu, setShowActionsMenu] = useState(false);
const [showActivityViewMenu, setShowActivityViewMenu] = useState(false);
const [sourceIssueExpanded, setSourceIssueExpanded] = useState(false); const [sourceIssueExpanded, setSourceIssueExpanded] = useState(false);
const [retriesExpanded, setRetriesExpanded] = useState(initialTab === "retries"); const [retriesExpanded, setRetriesExpanded] = useState(initialTab === "retries");
const [githubTrackingExpanded, setGithubTrackingExpanded] = useState(false); const [githubTrackingExpanded, setGithubTrackingExpanded] = useState(false);
@@ -934,6 +934,8 @@ export function TaskDetailContent({
const activityListRef = useRef<HTMLDivElement>(null); const activityListRef = useRef<HTMLDivElement>(null);
const moveButtonRef = useRef<HTMLButtonElement>(null); const moveButtonRef = useRef<HTMLButtonElement>(null);
const actionsMenuRef = useRef<HTMLDivElement>(null); const actionsMenuRef = useRef<HTMLDivElement>(null);
const activityViewMenuRef = useRef<HTMLDivElement>(null);
const activityViewButtonRef = useRef<HTMLButtonElement>(null);
// Plugin UI slots for task-detail-tab // Plugin UI slots for task-detail-tab
const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId); const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId);
@@ -1267,15 +1269,16 @@ export function TaskDetailContent({
setShowAgentPicker(false); setShowAgentPicker(false);
}, [task.id]); }, [task.id]);
// Close footer dropdown menus on outside click // Close task-detail dropdown menus on outside click
useEffect(() => { useEffect(() => {
const hasOpenMenu = showMoveMenu || showActionsMenu; const hasOpenMenu = showMoveMenu || showActionsMenu || showActivityViewMenu;
if (!hasOpenMenu) return; if (!hasOpenMenu) return;
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
const target = e.target as Node; const target = e.target as Node;
const inMoveMenu = moveMenuRef.current?.contains(target); const inMoveMenu = moveMenuRef.current?.contains(target);
const inActionsMenu = actionsMenuRef.current?.contains(target); const inActionsMenu = actionsMenuRef.current?.contains(target);
const inActivityViewMenu = activityViewMenuRef.current?.contains(target);
if (!inMoveMenu && showMoveMenu) { if (!inMoveMenu && showMoveMenu) {
setShowMoveMenu(false); setShowMoveMenu(false);
@@ -1283,15 +1286,18 @@ export function TaskDetailContent({
if (!inActionsMenu && showActionsMenu) { if (!inActionsMenu && showActionsMenu) {
setShowActionsMenu(false); setShowActionsMenu(false);
} }
if (!inActivityViewMenu && showActivityViewMenu) {
setShowActivityViewMenu(false);
}
}; };
document.addEventListener("mousedown", handleClick); document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick);
}, [showMoveMenu, showActionsMenu]); }, [showMoveMenu, showActionsMenu, showActivityViewMenu]);
// Close footer dropdown menus on Escape key (before modal Escape handler) // Close task-detail dropdown menus on Escape key (before modal Escape handler)
useEffect(() => { useEffect(() => {
const hasOpenMenu = showMoveMenu || showActionsMenu; const hasOpenMenu = showMoveMenu || showActionsMenu || showActivityViewMenu;
if (!hasOpenMenu) return; if (!hasOpenMenu) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -1299,12 +1305,13 @@ export function TaskDetailContent({
e.stopPropagation(); // Prevent modal from closing e.stopPropagation(); // Prevent modal from closing
if (showMoveMenu) setShowMoveMenu(false); if (showMoveMenu) setShowMoveMenu(false);
if (showActionsMenu) setShowActionsMenu(false); if (showActionsMenu) setShowActionsMenu(false);
if (showActivityViewMenu) setShowActivityViewMenu(false);
} }
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [showMoveMenu, showActionsMenu]); }, [showMoveMenu, showActionsMenu, showActivityViewMenu]);
// Reset spec edit state when task changes // Reset spec edit state when task changes
useEffect(() => { useEffect(() => {
@@ -2812,6 +2819,41 @@ export function TaskDetailContent({
closeMoveMenuAndFocusTrigger(); closeMoveMenuAndFocusTrigger();
}, [closeMoveMenuAndFocusTrigger]); }, [closeMoveMenuAndFocusTrigger]);
const activityViewOptions = useMemo<Array<{ value: ActivitySegment; label: string }>>(() => [
{ value: "current", label: t("taskDetail.activity.current", "Live") },
{ value: "feed", label: t("taskDetail.activity.feed", "Feed") },
{ value: "raw-logs", label: t("taskDetail.activity.raw", "Raw") },
], [t]);
const selectedActivityViewLabel = activityViewOptions.find((option) => option.value === activitySegment)?.label ?? activityViewOptions[0]?.label ?? "Live";
const selectActivityView = useCallback((value: ActivitySegment) => {
setActiveTab("chat");
setActivitySegment(value);
setShowActivityViewMenu(false);
}, []);
const handleActivityTabKeyDown = useCallback((event: React.KeyboardEvent<HTMLButtonElement>) => {
const shouldOpenMenu = event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown");
if (!shouldOpenMenu) {
return;
}
event.preventDefault();
setActiveTab("chat");
setShowActivityViewMenu(true);
}, []);
const handleActivityViewMenuKeyDown = useCallback((event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== "Escape") {
return;
}
event.preventDefault();
event.stopPropagation();
setShowActivityViewMenu(false);
activityViewButtonRef.current?.focus();
}, []);
useEffect(() => { useEffect(() => {
if (!showMoveMenu) { if (!showMoveMenu) {
return; return;
@@ -2821,6 +2863,58 @@ export function TaskDetailContent({
firstMenuItem?.focus(); firstMenuItem?.focus();
}, [showMoveMenu]); }, [showMoveMenu]);
useEffect(() => {
if (!showActivityViewMenu) {
return;
}
const selectedMenuItem = activityViewMenuRef.current?.querySelector<HTMLButtonElement>(".activity-view-menu-item[aria-current='true']");
const firstMenuItem = activityViewMenuRef.current?.querySelector<HTMLButtonElement>(".activity-view-menu-item");
(selectedMenuItem ?? firstMenuItem)?.focus();
}, [showActivityViewMenu]);
const renderActivityTab = () => (
<div className="detail-tab-dropdown" ref={activityViewMenuRef}>
{/*
FNXC:TaskDetailActivity 2026-06-30-23:59:
The top-level Activity tab is the only Activity view dropdown trigger. Keep the stable internal `chat` tab id and `current`/`feed`/`raw-logs` segment ids, but remove the in-panel Activity view select so desktop, embedded, and mobile tab strips have one canonical view switcher.
*/}
<button
ref={activityViewButtonRef}
type="button"
className={`detail-tab detail-tab--activity${activeTab === "chat" ? " detail-tab-active" : ""}`}
onClick={() => {
setActiveTab("chat");
setShowActivityViewMenu((value) => !value);
}}
onKeyDown={handleActivityTabKeyDown}
aria-haspopup="menu"
aria-expanded={showActivityViewMenu}
aria-label={t("taskDetail.tabs.activity", "Activity")}
title={t("taskDetail.activity.tabDropdownLabel", "Activity view: {{view}}", { view: selectedActivityViewLabel })}
>
<span>{t("taskDetail.tabs.activity", "Activity")}</span>
<ChevronDown className="detail-tab-chevron" aria-hidden="true" />
</button>
{showActivityViewMenu && (
<div className="activity-view-menu" role="menu" aria-label={t("taskDetail.activity.menuLabel", "Activity views")} onKeyDown={handleActivityViewMenuKeyDown}>
{activityViewOptions.map((option) => (
<button
key={option.value}
type="button"
className="activity-view-menu-item"
role="menuitem"
aria-current={activitySegment === option.value ? "true" : undefined}
onClick={() => selectActivityView(option.value)}
>
{option.label}
</button>
))}
</div>
)}
</div>
);
return ( return (
<div <div
className={`task-detail-content${embedded ? " task-detail-content--embedded" : ""}${isActivityExpanded ? " task-detail-content--chat-expanded" : ""}${isPlannerChatExpanded ? " task-detail-content--planner-chat-expanded" : ""}`} className={`task-detail-content${embedded ? " task-detail-content--embedded" : ""}${isActivityExpanded ? " task-detail-content--chat-expanded" : ""}${isPlannerChatExpanded ? " task-detail-content--planner-chat-expanded" : ""}`}
@@ -3259,21 +3353,11 @@ export function TaskDetailContent({
> >
{t("taskDetail.tabs.chat", "Chat")} {t("taskDetail.tabs.chat", "Chat")}
</button> </button>
<button {renderActivityTab()}
className={`detail-tab${activeTab === "chat" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("chat")}
>
{t("taskDetail.tabs.activity", "Activity")}
</button>
</> </>
) : ( ) : (
<> <>
<button {renderActivityTab()}
className={`detail-tab${activeTab === "chat" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("chat")}
>
{t("taskDetail.tabs.activity", "Activity")}
</button>
<button <button
className={`detail-tab${activeTab === "planner-chat" ? " detail-tab-active" : ""}`} className={`detail-tab${activeTab === "planner-chat" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("planner-chat")} onClick={() => setActiveTab("planner-chat")}
@@ -3434,24 +3518,10 @@ export function TaskDetailContent({
FNXC:TaskDetailActivity 2026-06-30-23:55: FNXC:TaskDetailActivity 2026-06-30-23:55:
The first Activity segment is user-facing Live but keeps the legacy `current` segment id. Activity expansion is segment-wide, so the same reachable toggle must remain present on Live, Feed, and Raw without fetching Raw outside the Raw segment. The first Activity segment is user-facing Live but keeps the legacy `current` segment id. Activity expansion is segment-wide, so the same reachable toggle must remain present on Live, Feed, and Raw without fetching Raw outside the Raw segment.
FNXC:TaskDetailActivity 2026-06-30-15:50: FNXC:TaskDetailActivity 2026-06-30-23:59:
Replacing the in-content subtabs with a native dropdown removes the horizontal tab shell on mobile while preserving keyboard operation and the legacy Activity segment ids (`current`, `feed`, `raw-logs`). The Activity tab in the top-level tab strip is now the view dropdown for Live, Feed, and Raw. The in-panel Activity view select was removed so Activity expansion remains the only toolbar affordance inside the panel while legacy routing and Raw-only fetching keep their stable ids (`chat`, `current`, `feed`, `raw-logs`).
*/} */}
<div className="activity-toolbar"> <div className="activity-toolbar activity-toolbar--expand-only">
<label className="visually-hidden" htmlFor={activitySelectorId}>
{t("taskDetail.activity.selectorLabel", "Activity view")}
</label>
<select
id={activitySelectorId}
className="select activity-view-select"
value={activitySegment}
onChange={(event) => setActivitySegment(event.target.value as ActivitySegment)}
aria-label={t("taskDetail.activity.selectorLabel", "Activity view")}
>
<option value="current">{t("taskDetail.activity.current", "Live")}</option>
<option value="feed">{t("taskDetail.activity.feed", "Feed")}</option>
<option value="raw-logs">{t("taskDetail.activity.raw", "Raw")}</option>
</select>
<button <button
type="button" type="button"
className="btn btn-icon btn-sm activity-expand-toggle" className="btn btn-icon btn-sm activity-expand-toggle"

View File

@@ -34,12 +34,35 @@ Omitted non-done TaskDetailModal renders open the top-level planner Chat first/d
*/ */
setupTaskDetailModalHooks(); setupTaskDetailModalHooks();
function activitySelector(): HTMLSelectElement { type ActivitySegmentTestValue = "current" | "feed" | "raw-logs";
return screen.getByRole("combobox", { name: "Activity view" }) as HTMLSelectElement;
const ACTIVITY_VIEW_LABELS: Record<ActivitySegmentTestValue, string> = {
current: "Live",
feed: "Feed",
"raw-logs": "Raw",
};
function openActivityViewMenu() {
const existingMenu = screen.queryByRole("menu", { name: "Activity views" });
if (!existingMenu) {
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
}
return screen.getByRole("menu", { name: "Activity views" });
} }
function selectActivityView(value: "current" | "feed" | "raw-logs") { function activityViewLabels(): string[] {
fireEvent.change(activitySelector(), { target: { value } }); openActivityViewMenu();
return screen.getAllByRole("menuitem").map((option) => option.textContent?.trim() ?? "");
}
function expectActivityView(value: ActivitySegmentTestValue) {
openActivityViewMenu();
expect(screen.getByRole("menuitem", { name: ACTIVITY_VIEW_LABELS[value] })).toHaveAttribute("aria-current", "true");
}
function selectActivityView(value: ActivitySegmentTestValue) {
openActivityViewMenu();
fireEvent.click(screen.getByRole("menuitem", { name: ACTIVITY_VIEW_LABELS[value] }));
} }
describe("TaskDetailModal", () => { describe("TaskDetailModal", () => {
@@ -492,11 +515,11 @@ describe("TaskDetailModal", () => {
fireEvent.click(screen.getByRole("button", { name: "Activity" })); fireEvent.click(screen.getByRole("button", { name: "Activity" }));
expect(container.querySelector(".activity-segmented-control")).toBeNull(); expect(container.querySelector(".activity-segmented-control")).toBeNull();
expect(container.querySelector(".activity-segment")).toBeNull(); expect(container.querySelector(".activity-segment")).toBeNull();
expect(Array.from(activitySelector().options).map((option) => option.textContent?.trim())).toEqual(["Live", "Feed", "Raw"]); expect(activityViewLabels()).toEqual(["Live", "Feed", "Raw"]);
expect(activitySelector().value).toBe("current"); expectActivityView("current");
selectActivityView("feed"); selectActivityView("feed");
expect(container.querySelector(".detail-activity")).toBeTruthy(); expect(container.querySelector(".detail-activity")).toBeTruthy();
expect(activitySelector().value).toBe("feed"); expectActivityView("feed");
}); });
it("switches to Feed segment via Activity tab and shows activity feed", () => { it("switches to Feed segment via Activity tab and shows activity feed", () => {
@@ -863,7 +886,7 @@ describe("TaskDetailModal", () => {
expect(mobileSectionRule).toContain("min-height: 0"); expect(mobileSectionRule).toContain("min-height: 0");
}); });
it("FN-6370/FN-6517 defines expanded chat chrome CSS for desktop and mobile", () => { it("FN-6370/FN-7351 defines expanded Activity chrome CSS for desktop and mobile", () => {
const css = readDashboardStylesSource(); const css = readDashboardStylesSource();
const expandedTitleRule = getCssRuleBlock(css, ".task-detail-content--chat-expanded .detail-title-row"); const expandedTitleRule = getCssRuleBlock(css, ".task-detail-content--chat-expanded .detail-title-row");
const expandedMetaRule = getCssRuleBlock(css, ".task-detail-content--chat-expanded .detail-meta"); const expandedMetaRule = getCssRuleBlock(css, ".task-detail-content--chat-expanded .detail-meta");
@@ -879,14 +902,14 @@ describe("TaskDetailModal", () => {
expect(expandedTitleRule).not.toContain("display: none"); expect(expandedTitleRule).not.toContain("display: none");
expect(expandedMetaRule).toContain("display: none"); expect(expandedMetaRule).toContain("display: none");
expect(expandedTabsRule).toContain("display: none"); expect(expandedTabsRule).toContain("display: flex");
expect(expandedActionsRule).toContain("display: none"); expect(expandedActionsRule).toContain("display: none");
expect(expandedHeaderRule).toContain("justify-content: space-between"); expect(expandedHeaderRule).toContain("justify-content: space-between");
expect(expandedBodyRule).toContain("flex: 1"); expect(expandedBodyRule).toContain("flex: 1");
expect(expandedBodyRule).toContain("min-height: 0"); expect(expandedBodyRule).toContain("min-height: 0");
expect(expandedSectionRule).toContain("margin-top: 0"); expect(expandedSectionRule).toContain("margin-top: 0");
expect(mobileTitleRule).not.toContain("display: none"); expect(mobileTitleRule).not.toContain("display: none");
expect(mobileTabsRule).toContain("display: none"); expect(mobileTabsRule).toContain("display: flex");
expect(mobileActionsRule).toContain("display: none"); expect(mobileActionsRule).toContain("display: none");
}); });
@@ -951,7 +974,7 @@ describe("TaskDetailModal", () => {
expect(screen.queryByTestId("task-chat-expand-toggle")).toBeNull(); expect(screen.queryByTestId("task-chat-expand-toggle")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Activity" })); fireEvent.click(screen.getByRole("button", { name: "Activity" }));
expect(activitySelector().value).toBe("current"); expectActivityView("current");
expect(screen.getByTestId("task-chat-expand-toggle")).toHaveAttribute("aria-label", "Expand activity to full modal"); expect(screen.getByTestId("task-chat-expand-toggle")).toHaveAttribute("aria-label", "Expand activity to full modal");
fireEvent.click(screen.getByTestId("task-chat-expand-toggle")); fireEvent.click(screen.getByTestId("task-chat-expand-toggle"));
@@ -999,7 +1022,7 @@ describe("TaskDetailModal", () => {
); );
expect(container.querySelector(".task-detail-content")).not.toHaveClass("task-detail-content--chat-expanded"); expect(container.querySelector(".task-detail-content")).not.toHaveClass("task-detail-content--chat-expanded");
expect(activitySelector().value).toBe("feed"); expectActivityView("feed");
expect(screen.getByTestId("task-chat-expand-toggle")).toHaveAttribute("aria-label", "Expand activity to full modal"); expect(screen.getByTestId("task-chat-expand-toggle")).toHaveAttribute("aria-label", "Expand activity to full modal");
}); });
@@ -1187,7 +1210,7 @@ describe("TaskDetailModal", () => {
); );
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active"); expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expect(activitySelector().value).toBe("feed"); expectActivityView("feed");
expect(container.querySelector(".detail-tabs .detail-tab:first-child")).toHaveTextContent("Chat"); expect(container.querySelector(".detail-tabs .detail-tab:first-child")).toHaveTextContent("Chat");
expect(container.querySelector(".detail-section--chat")).toBeNull(); expect(container.querySelector(".detail-section--chat")).toBeNull();
expect(container.querySelector(".detail-activity")).toBeTruthy(); expect(container.querySelector(".detail-activity")).toBeTruthy();

View File

@@ -16,11 +16,13 @@ describe("TaskDetailModal CSS contract", () => {
expect(css).toMatch(/\.detail-tab\s*\{[^}]*flex-shrink\s*:\s*0\s*;/); expect(css).toMatch(/\.detail-tab\s*\{[^}]*flex-shrink\s*:\s*0\s*;/);
}); });
it("FN-7344 keeps the Activity dropdown reachable on narrow task-detail surfaces", async () => { it("FN-7351 keeps the Activity tab dropdown reachable on narrow task-detail surfaces", async () => {
const css = await loadAllAppCssBaseOnly(); const css = await loadAllAppCssBaseOnly();
expect(css).toMatch(/\.activity-view-select\s*\{[^}]*min-inline-size\s*:\s*calc\(var\(--space-2xl\) \+ var\(--space-xl\) \+ var\(--space-lg\)\)\s*;/); expect(css).toMatch(/\.detail-tab-dropdown\s*\{[^}]*flex-shrink\s*:\s*0\s*;/);
expect(css).toMatch(/\.activity-view-select\s*\{[^}]*min-block-size\s*:\s*var\(--space-2xl\)\s*;/); expect(css).toMatch(/\.detail-tab--activity\s*\{[^}]*display\s*:\s*inline-flex\s*;/);
expect(css).toMatch(/\.activity-view-menu\s*\{[^}]*min-inline-size\s*:\s*100%\s*;/);
expect(css).not.toContain(".activity-view-select");
expect(css).not.toContain(".activity-segmented-control"); expect(css).not.toContain(".activity-segmented-control");
expect(css).not.toContain(".activity-segment"); expect(css).not.toContain(".activity-segment");
expect(css).not.toContain(".log-subview-toggle"); expect(css).not.toContain(".log-subview-toggle");

View File

@@ -2254,7 +2254,7 @@ describe("TaskDetailModal", () => {
}, { timeout: 3000 }); }, { timeout: 3000 });
fireEvent.click(screen.getByRole("button", { name: "Activity" })); fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.change(screen.getByRole("combobox", { name: "Activity view" }), { target: { value: "feed" } }); fireEvent.click(screen.getByRole("menuitem", { name: "Feed" }));
const activityList = container.querySelector(".detail-activity-list"); const activityList = container.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy(); expect(activityList).toBeTruthy();

View File

@@ -23,8 +23,19 @@ import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
setupTaskDetailModalHooks(); setupTaskDetailModalHooks();
function selectActivityView(value: "current" | "feed" | "raw-logs") { type ActivitySegmentTestValue = "current" | "feed" | "raw-logs";
fireEvent.change(screen.getByRole("combobox", { name: "Activity view" }), { target: { value } });
const ACTIVITY_VIEW_LABELS: Record<ActivitySegmentTestValue, string> = {
current: "Live",
feed: "Feed",
"raw-logs": "Raw",
};
function selectActivityView(value: ActivitySegmentTestValue) {
if (!screen.queryByRole("menu", { name: "Activity views" })) {
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
}
fireEvent.click(screen.getByRole("menuitem", { name: ACTIVITY_VIEW_LABELS[value] }));
} }
describe("TaskDetailModal", () => { describe("TaskDetailModal", () => {

View File

@@ -2429,7 +2429,7 @@ describe("TaskDetailModal", () => {
}, { timeout: 3000 }); }, { timeout: 3000 });
fireEvent.click(screen.getByRole("button", { name: "Activity" })); fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.change(screen.getByRole("combobox", { name: "Activity view" }), { target: { value: "feed" } }); fireEvent.click(screen.getByRole("menuitem", { name: "Feed" }));
const activityList = container.querySelector(".detail-activity-list"); const activityList = container.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy(); expect(activityList).toBeTruthy();

View File

@@ -361,23 +361,24 @@ describe("TaskDetailModal", () => {
expect(detailBodyBlock).not.toContain("overflow: hidden;"); expect(detailBodyBlock).not.toContain("overflow: hidden;");
}); });
it("keeps the Activity dropdown compact and reachable on mobile", () => { it("keeps the Activity tab dropdown compact and reachable on mobile", () => {
const css = readDashboardStylesSource(); const css = readDashboardStylesSource();
const selectorBlock = getExactCssRuleBlock(css, ".activity-view-select"); const tabDropdownBlock = getExactCssRuleBlock(css, ".detail-tab-dropdown");
const mobileBlock = getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".activity-view-select"); const activityTabBlock = getExactCssRuleBlock(css, ".detail-tab--activity");
const mobileSelectorBlock = getExactCssRuleBlock(mobileBlock, ".activity-view-select"); const menuBlock = getExactCssRuleBlock(css, ".activity-view-menu");
const mobileBlock = getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".activity-view-menu");
const mobileMenuBlock = getExactCssRuleBlock(mobileBlock, ".activity-view-menu");
expect(selectorBlock).toContain("min-inline-size: calc(var(--space-2xl) + var(--space-xl) + var(--space-lg));"); expect(tabDropdownBlock).toContain("position: relative;");
expect(selectorBlock).toContain("min-block-size: var(--space-2xl);"); expect(tabDropdownBlock).toContain("flex-shrink: 0;");
expect(selectorBlock).toContain("padding-block: calc(var(--space-xs) / 2);"); expect(activityTabBlock).toContain("display: inline-flex;");
expect(selectorBlock).toContain("padding-inline: var(--space-sm) var(--space-xl);"); expect(activityTabBlock).toContain("gap: var(--space-xs);");
expect(selectorBlock).toContain("font-size: var(--font-size-sm);"); expect(menuBlock).toContain("min-inline-size: 100%;");
expect(selectorBlock).toContain("line-height: var(--line-height-tight);"); expect(menuBlock).toContain("padding: var(--space-xs);");
expect(mobileSelectorBlock).toContain("flex: 1 1 auto;"); expect(mobileMenuBlock).toContain("min-inline-size: calc(100% + var(--space-xl));");
expect(mobileSelectorBlock).toContain("min-inline-size: 0;"); expect(css).not.toContain(".activity-view-select");
expect(css).not.toContain(".activity-segmented-control"); expect(css).not.toContain(".activity-segmented-control");
expect(css).not.toContain(".activity-segment"); expect(css).not.toContain(".activity-segment");
expect(css).not.toMatch(/\.task-detail-content--embedded\s+\.activity-view-select\s*\{/);
}); });
it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => { it("renders responsive structural classes (modal-lg, overlay, spacer, tabs, detail-body)", () => {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import React, { type ComponentProps } from "react"; import React, { type ComponentProps } from "react";
import type { AgentLogEntry } from "@fusion/core"; import type { AgentLogEntry } from "@fusion/core";
@@ -49,12 +49,35 @@ function topLevelTabLabels(): string[] {
.map((button) => button.textContent?.trim() ?? ""); .map((button) => button.textContent?.trim() ?? "");
} }
function activitySelector(): HTMLSelectElement { type ActivitySegmentTestValue = "current" | "feed" | "raw-logs";
return screen.getByRole("combobox", { name: "Activity view" }) as HTMLSelectElement;
const ACTIVITY_VIEW_LABELS: Record<ActivitySegmentTestValue, string> = {
current: "Live",
feed: "Feed",
"raw-logs": "Raw",
};
function openActivityViewMenu() {
const existingMenu = screen.queryByRole("menu", { name: "Activity views" });
if (!existingMenu) {
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
}
return screen.getByRole("menu", { name: "Activity views" });
} }
function activitySelectorLabels(): string[] { function activityViewLabels(): string[] {
return Array.from(activitySelector().options).map((option) => option.textContent?.trim() ?? ""); openActivityViewMenu();
return screen.getAllByRole("menuitem").map((option) => option.textContent?.trim() ?? "");
}
function expectActivityView(value: ActivitySegmentTestValue) {
openActivityViewMenu();
expect(screen.getByRole("menuitem", { name: ACTIVITY_VIEW_LABELS[value] })).toHaveAttribute("aria-current", "true");
}
function selectActivityView(value: ActivitySegmentTestValue) {
openActivityViewMenu();
fireEvent.click(screen.getByRole("menuitem", { name: ACTIVITY_VIEW_LABELS[value] }));
} }
function mockRawLogs(entries: AgentLogEntry[]) { function mockRawLogs(entries: AgentLogEntry[]) {
@@ -82,8 +105,10 @@ describe("TaskDetailModal Activity and planner Chat tab integration", () => {
expect(screen.getAllByRole("button", { name: "Chat" })).toHaveLength(1); expect(screen.getAllByRole("button", { name: "Chat" })).toHaveLength(1);
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active"); expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expect(screen.queryByTestId("task-planner-chat-panel")).not.toBeInTheDocument(); expect(screen.queryByTestId("task-planner-chat-panel")).not.toBeInTheDocument();
expect(activitySelectorLabels()).toEqual(["Live", "Feed", "Raw"]); expect(activityViewLabels()).toEqual(["Live", "Feed", "Raw"]);
expect(activitySelector().value).toBe("current"); expectActivityView("current");
expect(document.querySelector(".activity-view-select")).toBeNull();
expect(screen.queryByRole("combobox", { name: "Activity view" })).not.toBeInTheDocument();
expect(document.querySelector(".activity-segmented-control")).toBeNull(); expect(document.querySelector(".activity-segmented-control")).toBeNull();
expect(document.querySelector(".activity-segment")).toBeNull(); expect(document.querySelector(".activity-segment")).toBeNull();
expect(screen.queryByRole("tablist", { name: "Activity views" })).not.toBeInTheDocument(); expect(screen.queryByRole("tablist", { name: "Activity views" })).not.toBeInTheDocument();
@@ -94,18 +119,18 @@ describe("TaskDetailModal Activity and planner Chat tab integration", () => {
expect(screen.queryByRole("heading", { name: "Feed" })).not.toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Feed" })).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-log-viewer")).not.toBeInTheDocument(); expect(screen.queryByTestId("agent-log-viewer")).not.toBeInTheDocument();
await user.selectOptions(activitySelector(), "feed"); selectActivityView("feed");
expect(activitySelector().value).toBe("feed"); expectActivityView("feed");
expect(screen.queryByRole("form", { name: "Task activity composer" })).not.toBeInTheDocument(); expect(screen.queryByRole("form", { name: "Task activity composer" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Feed" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Feed" })).toBeInTheDocument();
expect(screen.getByText("Posted update")).toBeInTheDocument(); expect(screen.getByText("Posted update")).toBeInTheDocument();
expect(screen.queryByText("Existing steering guidance")).not.toBeInTheDocument(); expect(screen.queryByText("Existing steering guidance")).not.toBeInTheDocument();
expect(screen.queryByTestId("agent-log-viewer")).not.toBeInTheDocument(); expect(screen.queryByTestId("agent-log-viewer")).not.toBeInTheDocument();
await user.selectOptions(activitySelector(), "raw-logs"); selectActivityView("raw-logs");
expect(activitySelector().value).toBe("raw-logs"); expectActivityView("raw-logs");
expect(screen.queryByRole("form", { name: "Task activity composer" })).not.toBeInTheDocument(); expect(screen.queryByRole("form", { name: "Task activity composer" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Feed" })).not.toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Feed" })).not.toBeInTheDocument();
expect(screen.getByTestId("agent-log-viewer")).toBeInTheDocument(); expect(screen.getByTestId("agent-log-viewer")).toBeInTheDocument();
@@ -130,7 +155,7 @@ describe("TaskDetailModal Activity and planner Chat tab integration", () => {
expect(topLevelTabLabels().slice(0, 2)).toEqual(["Chat", "Activity"]); expect(topLevelTabLabels().slice(0, 2)).toEqual(["Chat", "Activity"]);
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active"); expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expect(activitySelector().value).toBe("current"); expectActivityView("current");
expect(screen.queryByTestId("task-planner-chat-panel")).not.toBeInTheDocument(); expect(screen.queryByTestId("task-planner-chat-panel")).not.toBeInTheDocument();
rerender( rerender(
@@ -167,7 +192,7 @@ describe("TaskDetailModal Activity and planner Chat tab integration", () => {
expect(topLevelTabLabels().slice(0, 2)).toEqual(["Chat", "Activity"]); expect(topLevelTabLabels().slice(0, 2)).toEqual(["Chat", "Activity"]);
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active"); expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expect(activitySelector().value).toBe("feed"); expectActivityView("feed");
expect(screen.getByRole("heading", { name: "Feed" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Feed" })).toBeInTheDocument();
}); });
@@ -197,7 +222,7 @@ describe("TaskDetailModal Activity and planner Chat tab integration", () => {
expect(screen.queryByRole("combobox", { name: "Activity view" })).not.toBeInTheDocument(); expect(screen.queryByRole("combobox", { name: "Activity view" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Activity" })); await user.click(screen.getByRole("button", { name: "Activity" }));
expect(activitySelectorLabels()).toEqual(["Live", "Feed", "Raw"]); expect(activityViewLabels()).toEqual(["Live", "Feed", "Raw"]);
expect(screen.queryByRole("tablist", { name: "Activity views" })).not.toBeInTheDocument(); expect(screen.queryByRole("tablist", { name: "Activity views" })).not.toBeInTheDocument();
expect(screen.getByText("No agent output yet. Live messages from Planner, Executor, Reviewer, and Merger agents will appear here.")).toBeInTheDocument(); expect(screen.getByText("No agent output yet. Live messages from Planner, Executor, Reviewer, and Merger agents will appear here.")).toBeInTheDocument();
expect(screen.getAllByRole("form", { name: "Task refinement composer" })).toHaveLength(1); expect(screen.getAllByRole("form", { name: "Task refinement composer" })).toHaveLength(1);

View File

@@ -35,8 +35,28 @@ vi.mock("../BranchGroupCard", () => ({
setupTaskDetailModalHooks(); setupTaskDetailModalHooks();
async function selectActivityView(user: ReturnType<typeof userEvent.setup>, value: "current" | "feed" | "raw-logs") { type ActivitySegmentTestValue = "current" | "feed" | "raw-logs";
await user.selectOptions(screen.getByRole("combobox", { name: "Activity view" }), value);
const ACTIVITY_VIEW_LABELS: Record<ActivitySegmentTestValue, string> = {
current: "Live",
feed: "Feed",
"raw-logs": "Raw",
};
function openActivityViewMenu() {
if (!screen.queryByRole("menu", { name: "Activity views" })) {
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
}
}
async function selectActivityView(user: ReturnType<typeof userEvent.setup>, value: ActivitySegmentTestValue) {
openActivityViewMenu();
await user.click(screen.getByRole("menuitem", { name: ACTIVITY_VIEW_LABELS[value] }));
}
function expectActivityView(value: ActivitySegmentTestValue) {
openActivityViewMenu();
expect(screen.getByRole("menuitem", { name: ACTIVITY_VIEW_LABELS[value] })).toHaveAttribute("aria-current", "true");
} }
function renderSummarizeTitleModal(overrides: Parameters<typeof makeTask>[0] = {}, props: Partial<ComponentProps<typeof TaskDetailModal>> = {}) { function renderSummarizeTitleModal(overrides: Parameters<typeof makeTask>[0] = {}, props: Partial<ComponentProps<typeof TaskDetailModal>> = {}) {
@@ -127,7 +147,7 @@ describe("TaskDetailModal planner Chat tab", () => {
renderTask("in-progress", "chat"); renderTask("in-progress", "chat");
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active"); expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expect(screen.getByRole("combobox", { name: "Activity view" })).toHaveValue("current"); expectActivityView("current");
}); });
it("routes explicit planner-chat requests to the new Chat tab", () => { it("routes explicit planner-chat requests to the new Chat tab", () => {
@@ -546,7 +566,7 @@ describe("TaskDetailModal Chat task merge", () => {
/>, />,
); );
expect(screen.getByRole("combobox", { name: "Activity view" })).toHaveValue("current"); expectActivityView("current");
expect(screen.getAllByRole("form", { name: "Task activity composer" })).toHaveLength(1); expect(screen.getAllByRole("form", { name: "Task activity composer" })).toHaveLength(1);
expect(screen.queryByText(/^Steering comment$/)).not.toBeInTheDocument(); expect(screen.queryByText(/^Steering comment$/)).not.toBeInTheDocument();
expect(screen.queryByText("Send operational guidance to the active task through steering comments.")).not.toBeInTheDocument(); expect(screen.queryByText("Send operational guidance to the active task through steering comments.")).not.toBeInTheDocument();
@@ -978,7 +998,7 @@ describe("TaskDetailModal in-review stall diagnostics", () => {
await user.click(screen.getByRole("button", { name: "View activity log" })); await user.click(screen.getByRole("button", { name: "View activity log" }));
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active"); expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expect(screen.getByRole("combobox", { name: "Activity view" })).toHaveValue("feed"); expectActivityView("feed");
const highlighted = document.querySelector(".detail-log-entry--stall-highlight .detail-log-action"); const highlighted = document.querySelector(".detail-log-entry--stall-highlight .detail-log-action");
expect(highlighted?.textContent).toContain("In-review stall surfaced [merge-blocker]"); expect(highlighted?.textContent).toContain("In-review stall surfaced [merge-blocker]");
}); });