FN-7307: add Activity segments to task details

Unify task-detail activity surfaces under one segmented Activity tab.

- Add Current, Feed, and Raw Logs segments inside the Activity tab while preserving legacy chat/logs entrypoints.
- Route log-based stall jumps and raw log loading through the new Activity segments.
- Update task-detail styles, plugin modal types, and tests for the segmented Activity layout.
- Add a minor changeset for the published CLI package.

Files changed:
 .changeset/fn-7307-activity-segments.md            |   7 ++
 .../dashboard/app/components/TaskDetailModal.css   |  42 +++++--
 .../dashboard/app/components/TaskDetailModal.tsx   | 122 +++++++++++--------
 .../TaskDetailModal.attachments-and-tabs.test.tsx  | 134 +++++++++++----------
 .../__tests__/TaskDetailModal.css.test.ts          |  11 ++
 ...lModal.inline-editing-and-integrations.test.tsx |   7 +-
 ...skDetailModal.models-progress-workflow.test.tsx |  26 ++--
 .../__tests__/TaskDetailModal.rendering.test.tsx   |   3 +-
 .../components/__tests__/TaskDetailModal.test.tsx  |  30 ++---
 packages/dashboard/app/hooks/useModalManager.ts    |   4 +-
 packages/dashboard/app/plugins/types.ts            |   4 +-
 11 files changed, 230 insertions(+), 160 deletions(-)

Fusion-Task-Id: FN-7307
Fusion-Task-Lineage: 20ce70a6-2c94-4980-b528-ae4079b59016
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 15:23:51 -07:00
parent 151800f387
commit e6be1f765f
11 changed files with 230 additions and 160 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add Activity segments for current task activity, Feed, and Raw Logs.
category: feature
dev: Task detail keeps legacy initialTab="logs" compatibility by routing to Activity → Feed.

View File

@@ -119,7 +119,7 @@ Task detail workflow badges share the board badge's slight token-based icon-to-l
background: var(--text-muted);
}
/* When the Agent Log tab is active, switch to flex layout so the log section
/* When Activity → Raw Logs is active, switch to flex layout so the log section
can stretch to fill the remaining space above the action bar.
The detail-body itself should NOT scroll; the agent-log-viewer scrolls instead. */
.detail-body--agent-log {
@@ -128,7 +128,7 @@ Task detail workflow badges share the board badge's slight token-based icon-to-l
overflow-y: hidden;
}
/* Chat mirrors the Agent Log fill-height layout: the modal body does not scroll;
/* Activity → Current mirrors the Raw Logs fill-height layout: the modal body does not scroll;
the transcript owns internal scrolling while the composer stays visible. */
.detail-body--chat {
display: flex;
@@ -793,7 +793,7 @@ The task-detail modal metadata must keep priority, execution mode, provenance, P
color: var(--color-error);
}
/* Agent Log tab: stretch to fill the remaining modal body height
/* Activity → Raw Logs: stretch to fill the remaining modal body height
so the log viewer uses all available space above the action bar. */
.detail-section--agent-log {
display: flex;
@@ -2353,36 +2353,54 @@ FNXC:TaskDetailTabs 2026-06-26-00:35:
font-weight: 600;
}
/* === Log Subview Toggle === */
.log-subview-toggle {
/* === Activity Segmented Control === */
/*
FNXC:TaskDetailActivity 2026-06-30-22:15:
The Activity top-level tab owns Current, Feed, and Raw Logs as an in-content segmented control. Keep this selector visually quieter than the top-level tab strip and horizontally reachable on narrow modal and embedded task-detail surfaces.
*/
.activity-segmented-control {
display: inline-flex;
max-inline-size: 100%;
gap: 0;
background: var(--card);
border: 1px solid var(--border);
border: var(--border-width) solid var(--border);
border-radius: var(--radius-md);
padding: 2px;
padding: calc(var(--space-xs) / 4);
margin-bottom: var(--space-md);
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-inline: contain;
touch-action: pan-x pan-y;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.log-subview-btn {
padding: 4px 12px;
.activity-segment {
flex: 0 0 auto;
padding: calc(var(--space-xs) / 2) var(--space-md);
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
font-size: 12px;
font-size: var(--font-size-sm);
font-weight: 500;
font-family: inherit;
touch-action: pan-x pan-y;
transition: color var(--transition-fast), background var(--transition-fast);
}
.log-subview-btn:hover {
.activity-segment:hover {
color: var(--text);
background: var(--card-hover);
}
.log-subview-btn-active {
.activity-segment:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.activity-segment-active {
color: var(--text);
background: var(--surface);
box-shadow: var(--shadow-sm);

View File

@@ -192,24 +192,32 @@ function formatDurationCompact(ageMs: number): string {
}
type TabId = "summary" | "definition" | "chat" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`;
type ActivitySegment = "current" | "feed" | "raw-logs";
/*
FNXC:TaskDetailActivityTab 2026-06-30-00:00:
The existing task activity/steering surface keeps the stable internal `chat` tab id for deep-link/plugin compatibility, but its top-level user-facing label is Activity. Activity is the implicit default for every task column, including done tasks; Summary remains explicitly available for completed work until a later subtask adds the separate planner-model Chat surface.
FNXC:TaskDetailActivityTab 2026-06-30-00:00:
Only an omitted initial tab is the implicit default. Preserve explicit `initialTab="chat"` requests from plugins and task-detail entrypoints so existing links continue to open the renamed Activity surface.
FNXC:TaskDetailActivity 2026-06-30-22:15:
Only an omitted initial tab is the implicit default. Preserve explicit `initialTab="chat"` requests from plugins and task-detail entrypoints so existing links continue to open Activity → Current. Legacy `initialTab="logs"` now routes to Activity → Feed because Logs no longer renders as a top-level task-detail tab.
*/
function resolveDefaultTab(initialTab: TabId | undefined, _column: ColumnId): TabId {
if (initialTab === "retries") {
return "definition";
}
if (initialTab === "logs") {
return "chat";
}
if (initialTab) {
return initialTab;
}
return "chat";
}
function resolveDefaultActivitySegment(initialTab: TabId | undefined): ActivitySegment {
return initialTab === "logs" ? "feed" : "current";
}
// Lazy-load the terminal so xterm + addons stay out of the main bundle (U11).
const LazySessionTerminal = lazy(() =>
import("./SessionTerminal").then((m) => ({ default: m.SessionTerminal })),
@@ -516,6 +524,7 @@ export function TaskDetailContent({
const columnLabel = useColumnLabel();
const fileBrowser = useFileBrowser();
const [activeTab, setActiveTab] = useState<TabId>(() => resolveDefaultTab(initialTab, task.column));
const [activitySegment, setActivitySegment] = useState<ActivitySegment>(() => resolveDefaultActivitySegment(initialTab));
const [chatExpanded, setChatExpanded] = useState(false);
// ── CLI agent session (U11) ────────────────────────────────────────────────
@@ -646,6 +655,7 @@ export function TaskDetailContent({
// Sync activeTab when the caller changes initialTab (e.g. opening a different tab)
useEffect(() => {
setActiveTab(resolveDefaultTab(initialTab, task.column));
setActivitySegment(resolveDefaultActivitySegment(initialTab));
if (initialTab === "retries") {
setRetriesExpanded(true);
}
@@ -668,7 +678,6 @@ export function TaskDetailContent({
setDescriptionExpanded(false);
}, [task.column, task.id]);
const [logSubview, setLogSubview] = useState<"activity" | "agent-log">("activity");
const [highlightStallCode, setHighlightStallCode] = useState<string | null>(null);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [titleOverflows, setTitleOverflows] = useState(false);
@@ -807,7 +816,7 @@ export function TaskDetailContent({
);
useEffect(() => {
if (activeTab !== "logs" || logSubview !== "activity") {
if (activeTab !== "chat" || activitySegment !== "feed") {
setHighlightStallCode(null);
return;
}
@@ -820,7 +829,7 @@ export function TaskDetailContent({
if (highlighted && typeof highlighted.scrollIntoView === "function") {
highlighted.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [activeTab, logSubview, highlightStallCode]);
}, [activeTab, activitySegment, highlightStallCode]);
const [refineFeedback, setRefineFeedback] = useState("");
const [isRefining, setIsRefining] = useState(false);
@@ -828,10 +837,10 @@ export function TaskDetailContent({
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
if (activeTab !== "chat" || isEditing) {
if (activeTab !== "chat" || activitySegment !== "current" || isEditing) {
setChatExpanded(false);
}
}, [activeTab, isEditing]);
}, [activeTab, activitySegment, isEditing]);
const [editTitle, setEditTitle] = useState(task.title || "");
const [editDescription, setEditDescription] = useState(task.description || "");
@@ -955,7 +964,7 @@ export function TaskDetailContent({
activeTaskIdRef.current = task.id;
}, [task.id]);
// Merged project settings for effective model resolution in Agent Log header
// Merged project settings for effective model resolution in Raw Logs header
const [settings, setSettings] = useState<Settings | undefined>(undefined);
const [globalSettings, setGlobalSettings] = useState<GlobalSettings | null>(null);
@@ -1012,7 +1021,7 @@ export function TaskDetailContent({
let cancelled = false;
/*
FNXC:ModelResolution 2026-06-27-10:52:
Task-detail model displays are task-scoped because project model lanes moved into workflow setting values. Fetch the effective settings for the selected task so Workflow, Chat, Agent Log, and Model editor surfaces resolve the same Executor/Reviewer/Planning models the engine uses.
Task-detail model displays are task-scoped because project model lanes moved into workflow setting values. Fetch the effective settings for the selected task so Workflow, Activity → Raw Logs, and Model editor surfaces resolve the same Executor/Reviewer/Planning models the engine uses.
*/
fetchTaskEffectiveSettings(task.id, projectId)
.catch(() => fetchSettings(projectId))
@@ -1774,7 +1783,7 @@ export function TaskDetailContent({
loadingMore: agentLogLoadingMore,
} = useAgentLogs(
task.id,
activeTab === "logs" && logSubview === "agent-log",
activeTab === "chat" && activitySegment === "raw-logs",
projectId,
);
const requestClose = useCallback(() => {
@@ -2792,7 +2801,7 @@ export function TaskDetailContent({
)}
</div>
</div>
<div className={`detail-body${activeTab === "logs" && logSubview === "agent-log" && !isEditing ? " detail-body--agent-log" : ""}${activeTab === "chat" && !isEditing ? " detail-body--chat" : ""}`}>
<div className={`detail-body${activeTab === "chat" && activitySegment === "raw-logs" && !isEditing ? " detail-body--agent-log" : ""}${activeTab === "chat" && activitySegment === "current" && !isEditing ? " detail-body--chat" : ""}`}>
{isEditing ? (
<div className="modal-edit-form">
<TaskForm
@@ -3159,12 +3168,6 @@ export function TaskDetailContent({
>
{t("taskDetail.tabs.definition", "Plan")}
</button>
<button
className={`detail-tab${activeTab === "logs" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("logs")}
>
{t("taskDetail.tabs.logs", "Logs")}
</button>
{(task.column === "in-progress" || task.column === "in-review" || task.column === "done") && (
<button
className={`detail-tab${activeTab === "changes" ? " detail-tab-active" : ""}`}
@@ -3282,41 +3285,58 @@ export function TaskDetailContent({
<TaskSummaryTab task={workingTask} pricingOverrides={globalSettings?.modelPricingOverrides} />
</div>
) : activeTab === "chat" ? (
<div className="detail-section detail-section--chat">
<TaskChatTab
task={workingTask}
projectId={projectId}
active={activeTab === "chat"}
addToast={addToast}
sessionLive={isCliSessionLive(cliSession)}
onTaskUpdated={handleChatTaskUpdated}
expanded={chatExpanded}
onToggleExpanded={() => setChatExpanded((value) => !value)}
effectiveModels={{
triage: toTaskChatModelInfo(resolveEffectivePlanning(workingTask, agentLogEntries, settings)),
executor: toTaskChatModelInfo(resolveEffectiveExecutor(workingTask, agentLogEntries, assignedAgent, settings)),
reviewer: toTaskChatModelInfo(resolveEffectiveValidator(workingTask, agentLogEntries, assignedAgent, settings)),
merger: toTaskChatModelInfo(resolveEffectiveValidator(workingTask, agentLogEntries, assignedAgent, settings)),
}}
/>
</div>
) : activeTab === "logs" ? (
<div className={`detail-section${logSubview === "agent-log" ? " detail-section--agent-log" : ""}`}>
<div className="log-subview-toggle">
<div className={`detail-section detail-section--activity${activitySegment === "current" ? " detail-section--chat" : ""}${activitySegment === "raw-logs" ? " detail-section--agent-log" : ""}`}>
{/*
FNXC:TaskDetailActivity 2026-06-30-22:15:
Activity owns the existing steering/current view, Feed, and Raw Logs inside one segmented control. The later planner-model Chat tab is intentionally out of scope, so the stable top-level tab id remains `chat` while the legacy `logs` id lands on the Feed segment.
*/}
<div className="activity-segmented-control" role="tablist" aria-label={t("taskDetail.activity.segmentsLabel", "Activity views")}>
<button
className={`log-subview-btn${logSubview === "activity" ? " log-subview-btn-active" : ""}`}
onClick={() => setLogSubview("activity")}
type="button"
role="tab"
aria-selected={activitySegment === "current"}
className={`activity-segment${activitySegment === "current" ? " activity-segment-active" : ""}`}
onClick={() => setActivitySegment("current")}
>
{t("taskDetail.logs.activity", "Activity")}
{t("taskDetail.activity.current", "Current")}
</button>
<button
className={`log-subview-btn${logSubview === "agent-log" ? " log-subview-btn-active" : ""}`}
onClick={() => setLogSubview("agent-log")}
type="button"
role="tab"
aria-selected={activitySegment === "feed"}
className={`activity-segment${activitySegment === "feed" ? " activity-segment-active" : ""}`}
onClick={() => setActivitySegment("feed")}
>
{t("taskDetail.logs.agentLog", "Agent Log")}
{t("taskDetail.activity.feed", "Feed")}
</button>
<button
type="button"
role="tab"
aria-selected={activitySegment === "raw-logs"}
className={`activity-segment${activitySegment === "raw-logs" ? " activity-segment-active" : ""}`}
onClick={() => setActivitySegment("raw-logs")}
>
{t("taskDetail.activity.rawLogs", "Raw Logs")}
</button>
</div>
{logSubview === "agent-log" ? (
{activitySegment === "current" ? (
<TaskChatTab
task={workingTask}
projectId={projectId}
active={activeTab === "chat" && activitySegment === "current"}
addToast={addToast}
sessionLive={isCliSessionLive(cliSession)}
onTaskUpdated={handleChatTaskUpdated}
expanded={chatExpanded}
onToggleExpanded={() => setChatExpanded((value) => !value)}
effectiveModels={{
triage: toTaskChatModelInfo(resolveEffectivePlanning(workingTask, agentLogEntries, settings)),
executor: toTaskChatModelInfo(resolveEffectiveExecutor(workingTask, agentLogEntries, assignedAgent, settings)),
reviewer: toTaskChatModelInfo(resolveEffectiveValidator(workingTask, agentLogEntries, assignedAgent, settings)),
merger: toTaskChatModelInfo(resolveEffectiveValidator(workingTask, agentLogEntries, assignedAgent, settings)),
}}
/>
) : activitySegment === "raw-logs" ? (
<AgentLogViewer
entries={agentLogEntries}
loading={agentLogLoading}
@@ -3329,8 +3349,8 @@ export function TaskDetailContent({
totalCount={agentLogTotal}
/>
) : (
<div className="detail-activity">
<h4>{t("taskDetail.logs.activityHeading", "Activity")}</h4>
<div className="detail-activity" role="tabpanel">
<h4>{t("taskDetail.activity.feedHeading", "Feed")}</h4>
{(workingTask as typeof workingTask & { activityLogTruncatedCount?: number }).activityLogTruncatedCount ? (
<div className="detail-log-truncated">
{t("taskDetail.logs.truncated", "Showing the most recent {{count}} activity entries.", { count: workingTask.log.length })}
@@ -3426,8 +3446,8 @@ export function TaskDetailContent({
type="button"
className="btn btn-sm detail-in-review-stall-jump"
onClick={() => {
setActiveTab("logs");
setLogSubview("activity");
setActiveTab("chat");
setActivitySegment("feed");
setHighlightStallCode(workingTask.inReviewStall?.code ?? null);
}}
>
@@ -3474,8 +3494,8 @@ export function TaskDetailContent({
type="button"
className="btn btn-sm detail-in-review-stall-jump"
onClick={() => {
setActiveTab("logs");
setLogSubview("activity");
setActiveTab("chat");
setActivitySegment("feed");
setHighlightStallCode(workingTask.stalePausedReview?.code ?? null);
}}
>

View File

@@ -368,8 +368,9 @@ describe("TaskDetailModal", () => {
/>,
);
// Click Logs tab — Activity is the default subview
fireEvent.click(screen.getByText("Logs"));
// Click Activity tab — Activity is the default subview
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
const activityList = container.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy();
@@ -453,27 +454,23 @@ describe("TaskDetailModal", () => {
);
expect(screen.getByText("Plan")).toBeTruthy();
expect(screen.getByText("Logs")).toBeTruthy();
// Logs subview controls should NOT be visible on the default Activity tab.
expect(container.querySelector(".log-subview-toggle")).toBeNull();
expect(screen.queryByRole("button", { name: "Logs" })).toBeNull();
expect(container.querySelector(".activity-segmented-control")).toBeTruthy();
expect(screen.queryByText("Agent Log")).toBeNull();
// Chat content should be visible by default.
const segments = screen.getAllByRole("tab");
expect(segments.map((segment) => segment.textContent)).toEqual(["Current", "Feed", "Raw Logs"]);
expect(screen.getByRole("tab", { name: "Current" })).toHaveAttribute("aria-selected", "true");
expect(container.querySelector(".detail-section--chat")).toBeTruthy();
expect(container.querySelector("[data-testid='task-chat-tab']")).toBeTruthy();
// Activity section should NOT be visible initially.
expect(container.querySelector(".detail-activity")).toBeNull();
// Agent log viewer should not be visible.
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
// After clicking Logs tab, the subview toggle buttons should appear.
fireEvent.click(screen.getByText("Logs"));
const logSubviewToggle = container.querySelector(".log-subview-toggle");
expect(logSubviewToggle).toBeTruthy();
expect(logSubviewToggle!.textContent).toContain("Activity");
expect(logSubviewToggle!.textContent).toContain("Agent Log");
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
expect(container.querySelector(".detail-activity")).toBeTruthy();
expect(screen.getByRole("tab", { name: "Feed" })).toHaveAttribute("aria-selected", "true");
});
it("switches to Activity subview via Logs tab and shows activity feed", () => {
it("switches to Feed segment via Activity tab and shows activity feed", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
@@ -491,8 +488,9 @@ describe("TaskDetailModal", () => {
/>,
);
// Click Logs tab — Activity is the default subview
fireEvent.click(screen.getByText("Logs"));
// Click Activity tab — Activity is the default subview
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
// Activity section should be visible
expect(container.querySelector(".detail-activity")).toBeTruthy();
@@ -502,7 +500,7 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".markdown-body")).toBeNull();
});
it("Activity subview renders log entries correctly", () => {
it("Feed segment renders log entries correctly", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
@@ -521,8 +519,9 @@ describe("TaskDetailModal", () => {
/>,
);
// Click Logs tab — Activity is the default subview
fireEvent.click(screen.getByText("Logs"));
// Click Activity tab — Activity is the default subview
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
const activityList = container.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy();
@@ -538,7 +537,7 @@ describe("TaskDetailModal", () => {
expect(logEntries[2].textContent).toContain("Created task");
});
it("Activity subview keeps action/outcome rendering intact", () => {
it("Feed segment keeps action/outcome rendering intact", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
@@ -556,7 +555,8 @@ describe("TaskDetailModal", () => {
/>,
);
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
const actions = container.querySelectorAll(".detail-log-action");
const outcomes = container.querySelectorAll(".detail-log-outcome");
@@ -581,7 +581,7 @@ describe("TaskDetailModal", () => {
expect(timestampRule).not.toContain("color: var(--text);");
});
it("Activity subview shows empty state when no logs", () => {
it("Feed segment shows empty state when no logs", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ log: [] })}
@@ -594,8 +594,9 @@ describe("TaskDetailModal", () => {
/>,
);
// Click Logs tab — Activity is the default subview
fireEvent.click(screen.getByText("Logs"));
// Click Activity tab — Activity is the default subview
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
// Activity section should be visible
expect(container.querySelector(".detail-activity")).toBeTruthy();
@@ -606,7 +607,7 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".detail-activity-list")).toBeNull();
});
it("can switch between all tabs and Logs subviews", () => {
it("can switch between all tabs and Activity segments", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
@@ -627,18 +628,19 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".markdown-body")).toBeTruthy();
expect(container.querySelector(".detail-activity")).toBeNull();
// Switch to Logs tab (Activity subview is default)
fireEvent.click(screen.getByText("Logs"));
// Switch to Activity tab (Feed segment is default)
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
expect(container.querySelector(".detail-activity")).toBeTruthy();
expect(container.querySelector(".markdown-body")).toBeNull();
// Switch to Agent Log subview within Logs tab
fireEvent.click(screen.getByText("Agent Log"));
// Switch to Raw Activity segment within Activity tab
fireEvent.click(screen.getByText("Raw Logs"));
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy();
expect(container.querySelector(".detail-activity")).toBeNull();
// Switch back to Activity subview within Logs tab.
fireEvent.click(container.querySelector<HTMLButtonElement>(".log-subview-toggle .log-subview-btn")!);
// Switch back to Feed segment within Activity tab.
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
expect(container.querySelector(".detail-activity")).toBeTruthy();
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
@@ -654,7 +656,7 @@ describe("TaskDetailModal", () => {
});
it("switches to Agent Log subview via Logs tab and back", async () => {
it("switches to Raw Activity segment via Activity tab and back", async () => {
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
const mockUseAgentLogs = vi.mocked(useAgentLogs);
@@ -670,9 +672,10 @@ describe("TaskDetailModal", () => {
/>,
);
// Click Logs tab, then Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
// Click Activity tab, then Raw Activity segment
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
fireEvent.click(screen.getByText("Raw Logs"));
// Agent log viewer should appear
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy();
@@ -687,7 +690,7 @@ describe("TaskDetailModal", () => {
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
});
it("passes enabled=true to useAgentLogs only when Logs → Agent Log subview is active", async () => {
it("passes enabled=true to useAgentLogs only when Activity → Raw Logs segment is active", async () => {
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
const mockUseAgentLogs = vi.mocked(useAgentLogs);
mockUseAgentLogs.mockClear();
@@ -708,13 +711,14 @@ describe("TaskDetailModal", () => {
const initialCall = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(initialCall[1]).toBe(false);
// Switch to Logs tab (Activity subview is default) — enabled should still be false
fireEvent.click(screen.getByText("Logs"));
// Switch to Activity tab (Feed segment is default) — enabled should still be false
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
const afterLogsClick = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(afterLogsClick[1]).toBe(false);
// Switch to Agent Log subview — enabled should become true
fireEvent.click(screen.getByText("Agent Log"));
// Switch to Raw Activity segment — enabled should become true
fireEvent.click(screen.getByText("Raw Logs"));
const afterAgentLog = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(afterAgentLog[1]).toBe(true);
});
@@ -743,7 +747,7 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".markdown-body")).toBeNull();
});
it("shows correct top-level tabs including Logs", async () => {
it("shows correct top-level tabs without Logs", async () => {
const { container } = render(
<TaskDetailModal
task={makeTask()}
@@ -757,18 +761,19 @@ describe("TaskDetailModal", () => {
);
// For an in-progress task (no workflow steps, no merge commit), the
// top-level tabs are: Activity, Plan, Logs, Changes, Review, Comments,
// top-level tabs are: Activity, Plan, Changes, Review, Comments,
// Artifacts, Model, Workflow, Stats, Routing.
const tabTexts = ["Activity", "Plan", "Logs", "Changes", "Review", "Comments", "Artifacts", "Model", "Workflow", "Stats", "Routing"];
const tabTexts = ["Activity", "Plan", "Changes", "Review", "Comments", "Artifacts", "Model", "Workflow", "Stats", "Routing"];
const tabs = screen.getAllByRole("button").filter((b) =>
tabTexts.includes(b.textContent || "")
);
expect(tabs.map((tab) => tab.textContent)).toEqual(tabTexts);
expect(tabs[0].textContent).toBe("Activity");
expect(tabs[1].textContent).toBe("Plan");
expect(tabs[2].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(screen.queryByRole("button", { name: "Logs" })).toBeNull();
expect(container.querySelectorAll(".detail-tab").length).toBe(11);
expect(container.querySelectorAll(".detail-tab").length).toBe(10);
// Workflow tab should always appear even when no workflow steps are configured
expect(screen.getByText("Workflow")).toBeInTheDocument();
// Commits tab should NOT appear for non-done tasks
@@ -985,10 +990,11 @@ describe("TaskDetailModal", () => {
/>,
);
expect(screen.getByRole("button", { name: "Logs" })).toHaveClass("detail-tab-active");
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expect(screen.getByRole("tab", { name: "Feed" })).toHaveAttribute("aria-selected", "true");
expect(container.querySelector(".detail-tabs .detail-tab:first-child")).toHaveTextContent("Activity");
expect(container.querySelector(".detail-tabs .detail-tab:first-child")).not.toHaveClass("detail-tab-active");
expect(container.querySelector(".detail-section--chat")).toBeNull();
expect(container.querySelector(".detail-activity")).toBeTruthy();
});
it("FN-6574 renders Definition-only content when initialTab requests definition", () => {
@@ -1040,8 +1046,9 @@ describe("TaskDetailModal", () => {
expect(chatSection).toBeTruthy();
expect(chatSection!.querySelector("[data-testid='task-chat-tab']")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Logs" }));
fireEvent.click(screen.getByText("Agent Log"));
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
fireEvent.click(screen.getByText("Raw Logs"));
expect(container.querySelector(".detail-body--chat")).toBeNull();
expect(container.querySelector(".detail-section--chat")).toBeNull();
expect(container.querySelector(".detail-body--agent-log")).toBeTruthy();
@@ -1069,8 +1076,8 @@ describe("TaskDetailModal", () => {
});
});
describe("Agent Log full-height layout", () => {
it("applies detail-body--agent-log class when Logs → Agent Log subview is active", () => {
describe("Raw Logs full-height layout", () => {
it("applies detail-body--agent-log class when Activity → Raw Logs segment is active", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
@@ -1086,11 +1093,12 @@ describe("TaskDetailModal", () => {
// Initially, detail-body should NOT have the agent-log modifier
expect(container.querySelector(".detail-body--agent-log")).toBeNull();
// Switch to Logs tab, then Agent Log subview
fireEvent.click(screen.getByText("Logs"));
expect(container.querySelector(".detail-body--agent-log")).toBeNull(); // Activity subview default
// Switch to Activity tab, then Raw Activity segment
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
expect(container.querySelector(".detail-body--agent-log")).toBeNull(); // Feed segment default
fireEvent.click(screen.getByText("Agent Log"));
fireEvent.click(screen.getByText("Raw Logs"));
// detail-body should now have the agent-log modifier class
expect(container.querySelector(".detail-body--agent-log")).toBeTruthy();
@@ -1115,9 +1123,10 @@ describe("TaskDetailModal", () => {
/>,
);
// Switch to Logs tab, then Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
// Switch to Activity tab, then Raw Activity segment
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
fireEvent.click(screen.getByText("Raw Logs"));
// The section wrapping AgentLogViewer should have the full-height class
const section = container.querySelector(".detail-section--agent-log");
@@ -1138,9 +1147,10 @@ describe("TaskDetailModal", () => {
/>,
);
// Switch to Logs tab, then Agent Log subview first
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
// Switch to Activity tab, then Raw Activity segment first
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
fireEvent.click(screen.getByText("Raw Logs"));
expect(container.querySelector(".detail-body--agent-log")).toBeTruthy();
// Now enter edit mode via the pencil button in the header

View File

@@ -15,4 +15,15 @@ describe("TaskDetailModal CSS contract", () => {
expect(css).toMatch(/\.detail-tabs\s*\{[^}]*touch-action\s*:\s*pan-x\s+pan-y\s*;/);
expect(css).toMatch(/\.detail-tab\s*\{[^}]*flex-shrink\s*:\s*0\s*;/);
});
it("FN-7307 keeps Activity segments reachable on narrow task-detail surfaces", async () => {
const css = await loadAllAppCssBaseOnly();
expect(css).toMatch(/\.activity-segmented-control\s*\{[^}]*max-inline-size\s*:\s*100%\s*;/);
expect(css).toMatch(/\.activity-segmented-control\s*\{[^}]*overflow-x\s*:\s*auto\s*;/);
expect(css).toMatch(/\.activity-segmented-control\s*\{[^}]*touch-action\s*:\s*pan-x\s+pan-y\s*;/);
expect(css).toMatch(/\.activity-segment\s*\{[^}]*flex\s*:\s*0\s+0\s+auto\s*;/);
expect(css).not.toContain(".log-subview-toggle");
expect(css).not.toContain(".log-subview-btn");
});
});

View File

@@ -2112,7 +2112,8 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".markdown-body")).toBeTruthy();
}, { timeout: 3000 });
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
const activityList = container.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy();
@@ -2258,9 +2259,9 @@ describe("TaskDetailModal", () => {
/>
);
// Only standard tabs should be visible (Definition, Logs, etc.)
// Only standard tabs should be visible (Activity, Plan, etc.) without the legacy top-level Logs tab.
expect(screen.getByText("Plan")).toBeDefined();
expect(screen.getByText("Logs")).toBeDefined();
expect(screen.queryByRole("button", { name: "Logs" })).toBeNull();
// Plugin tabs should not exist
expect(screen.queryByText("Plugin A Tab")).toBeNull();
});

View File

@@ -24,7 +24,7 @@ import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
setupTaskDetailModalHooks();
describe("TaskDetailModal", () => {
describe("Agent Log model resolution", () => {
describe("Raw Logs model resolution", () => {
// AgentLogViewer only renders the model header when entries.length > 0,
// so we mock useAgentLogs to return at least one entry.
const mockLogEntry = { timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const };
@@ -100,8 +100,8 @@ describe("TaskDetailModal", () => {
}
async function openAgentLogAndExpandModelDetails(container: HTMLElement) {
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Raw Logs" }));
await waitFor(() => {
const header = container.querySelector("[data-testid='agent-log-model-header']");
@@ -116,7 +116,7 @@ describe("TaskDetailModal", () => {
return container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement;
}
it("uses task effective settings success path for Agent Log model display", async () => {
it("uses task effective settings success path for Raw Logs model display", async () => {
const { fetchTaskEffectiveSettings, fetchSettings } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
@@ -574,8 +574,8 @@ describe("TaskDetailModal", () => {
/>,
);
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Raw Logs" }));
await waitFor(() => {
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
@@ -631,8 +631,8 @@ describe("TaskDetailModal", () => {
/>,
);
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Raw Logs" }));
await waitFor(() => {
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
@@ -872,7 +872,7 @@ describe("TaskDetailModal", () => {
expect(segments[1].getAttribute("data-tooltip")).toBe("Add tests (in-progress)");
});
it("step progress only renders in Definition tab, not in Agent Log subview", () => {
it("step progress only renders in Definition tab, not in Raw Logs segment", () => {
const { container } = render(
<TaskDetailModal
initialTab="definition"
@@ -894,11 +894,11 @@ describe("TaskDetailModal", () => {
// Should be visible in Definition tab
expect(container.querySelector(".detail-step-progress")).toBeTruthy();
// Switch to Logs tab, then Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
// Switch to Activity tab, then Raw Logs segment
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Raw Logs" }));
// Should not be visible in Agent Log subview
// Should not be visible in Raw Logs segment
expect(container.querySelector(".detail-step-progress")).toBeNull();
});

View File

@@ -2428,7 +2428,8 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".markdown-body")).toBeTruthy();
}, { timeout: 3000 });
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
fireEvent.click(screen.getByRole("tab", { name: "Feed" }));
const activityList = container.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy();

View File

@@ -265,8 +265,8 @@ describe("TaskDetailModal GitHub tracking CTA", () => {
});
});
describe("TaskDetailModal Logs activity loading", () => {
function renderLogsModal(task: ReturnType<typeof makeTask> | Record<string, unknown>) {
describe("TaskDetailModal Activity feed loading", () => {
function renderActivityFeedModal(task: ReturnType<typeof makeTask> | Record<string, unknown>) {
return render(
<TaskDetailModal
task={task as any}
@@ -295,13 +295,13 @@ describe("TaskDetailModal Logs activity loading", () => {
vi.mocked(fetchTaskDetail).mockReset();
vi.mocked(fetchTaskDetail).mockImplementationOnce(() => new Promise(() => {}));
renderLogsModal(makeSlimTask());
renderActivityFeedModal(makeSlimTask());
expect(await screen.findByRole("status")).toHaveTextContent("Loading activity…");
expect(screen.queryByText("(no activity)")).not.toBeInTheDocument();
});
it("shows activity loading when switching to Logs before slim task detail resolves", async () => {
it("shows activity loading when switching to Activity Feed before slim task detail resolves", async () => {
const user = userEvent.setup();
const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockReset();
@@ -320,7 +320,8 @@ describe("TaskDetailModal Logs activity loading", () => {
/>,
);
await user.click(screen.getByRole("button", { name: "Logs" }));
await user.click(screen.getByRole("button", { name: "Activity" }));
await user.click(screen.getByRole("tab", { name: "Feed" }));
expect(await screen.findByRole("status")).toHaveTextContent("Loading activity…");
expect(screen.queryByText("(no activity)")).not.toBeInTheDocument();
});
@@ -330,7 +331,7 @@ describe("TaskDetailModal Logs activity loading", () => {
vi.mocked(fetchTaskDetail).mockReset();
vi.mocked(fetchTaskDetail).mockResolvedValueOnce(makeTask({ id: "FN-6040", prompt: "# Loaded", log: [] }));
renderLogsModal(makeSlimTask());
renderActivityFeedModal(makeSlimTask());
expect(await screen.findByText("(no activity)")).toBeInTheDocument();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
@@ -348,7 +349,7 @@ describe("TaskDetailModal Logs activity loading", () => {
],
}));
const { container } = renderLogsModal(makeSlimTask());
const { container } = renderActivityFeedModal(makeSlimTask());
await screen.findByText("newer entry");
const actions = Array.from(container.querySelectorAll(".detail-log-action")).map((node) => node.textContent);
@@ -366,7 +367,7 @@ describe("TaskDetailModal Logs activity loading", () => {
activityLogTruncatedCount: 25,
} as any));
renderLogsModal(makeSlimTask());
renderActivityFeedModal(makeSlimTask());
expect(await screen.findByText("Showing the most recent 1 activity entries.")).toBeInTheDocument();
expect(screen.getByText("kept entry")).toBeInTheDocument();
@@ -430,8 +431,8 @@ describe("TaskDetailModal Chat task merge", () => {
});
});
describe("TaskDetailModal Logs agent loading", () => {
it("shows the Agent Log loading indicator when entering the subview", async () => {
describe("TaskDetailModal Raw Logs agent loading", () => {
it("shows the Raw Logs loading indicator when entering the segment", async () => {
const user = userEvent.setup();
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
const mockUseAgentLogs = vi.mocked(useAgentLogs);
@@ -458,8 +459,9 @@ describe("TaskDetailModal Logs agent loading", () => {
/>,
);
await user.click(screen.getByRole("button", { name: "Logs" }));
await user.click(screen.getByRole("button", { name: "Agent Log" }));
await user.click(screen.getByRole("button", { name: "Activity" }));
await user.click(screen.getByRole("tab", { name: "Feed" }));
await user.click(screen.getByRole("tab", { name: "Raw Logs" }));
expect(screen.getByText("Loading agent logs…")).toBeInTheDocument();
expect(screen.queryByText("No agent output yet.")).not.toBeInTheDocument();
@@ -688,8 +690,8 @@ describe("TaskDetailModal in-review stall diagnostics", () => {
expect(screen.getByText("Open the Review tab to see which step is blocking, then fix the failure or override the step.")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View activity log" }));
expect(screen.getByRole("button", { name: "Logs" })).toHaveClass("detail-tab-active");
expect(document.querySelector(".log-subview-toggle .log-subview-btn-active")).toHaveTextContent("Activity");
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expect(screen.getByRole("tab", { name: "Feed" })).toHaveAttribute("aria-selected", "true");
const highlighted = document.querySelector(".detail-log-entry--stall-highlight .detail-log-action");
expect(highlighted?.textContent).toContain("In-review stall surfaced [merge-blocker]");
});

View File

@@ -6,8 +6,8 @@ import type { ToastType } from "./useToast";
import { removeScopedItem } from "../utils/projectStorage";
/*
FNXC:TaskDetailActivityTab 2026-06-30-00:00:
Keep `chat` as the public initial-tab id for the renamed Activity task-detail tab so existing dashboard callers and deep links remain compatible until the future planner Chat tab ships under its own contract.
FNXC:TaskDetailActivity 2026-06-30-22:15:
Keep `chat` as the public initial-tab id for the renamed Activity task-detail tab so existing dashboard callers and deep links remain compatible until the future planner Chat tab ships under its own contract. Legacy `logs` callers are also preserved by TaskDetailModal, which opens Activity → Feed instead of rendering a top-level Logs tab.
*/
export type DetailTaskTab =
| "summary"

View File

@@ -14,8 +14,8 @@ import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
/**
* Tab identifiers for the task detail modal. Mirrors the dashboard's local enum.
*
* FNXC:TaskDetailActivityTab 2026-06-30-00:00:
* Plugins should continue passing `chat` to open the renamed Activity tab; the id is stable compatibility surface, while the top-level label is no longer Chat.
* FNXC:TaskDetailActivity 2026-06-30-22:15:
* Plugins should continue passing `chat` to open the renamed Activity tab; the id is stable compatibility surface, while the top-level label is no longer Chat. Legacy `logs` requests remain accepted by the host and route to Activity → Feed because Logs is no longer a visible top-level tab.
*/
export type DetailTaskTab = "summary" | "chat" | "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "pr" | "retries";