refactor(FN-874): align TaskDetailModal tests and docs with unified Logs tab

- Rewrite TaskDetailModal tests to reflect the unified Logs tab (agent log + activity log merged)
- Update test assertions to match new tab structure and component behavior
- Update README to describe the unified Logs tab layout
This commit is contained in:
gsxdsm
2026-04-04 10:03:50 -07:00
parent 4ef2d78ee9
commit 3ab2af1e6a
2 changed files with 86 additions and 48 deletions

View File

@@ -51,7 +51,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level
- **Layered Model Dropdowns**: Shared model combobox menus render in a top-level portal attached to `document.body`, so they stay above board columns and scrollable modal content instead of being clipped behind surrounding dashboard surfaces.
- **Fuzzy Model Search**: Model dropdown search (`filterModels`) supports fuzzy matching so users can find models despite minor typing imperfections. Three matching strategies are applied in order (first match wins): (1) **separator-insensitive substring** — hyphens, underscores, dots, and slashes are stripped before comparison, so `gpt4o` finds `gpt-4o`; (2) **subsequence matching** (≥ 3 chars) — characters must appear in order within a single token but need not be contiguous, so `cld` finds `claude`; (3) **typo tolerance** (≥ 4 chars) — Damerau-Levenshtein edit distance ≤ 1 supports single-character insertion, deletion, substitution, and adjacent transposition, so `sonet` finds `sonnet`. Multi-term space-separated queries use AND logic. Result ordering is stable (input-array order, no score re-sorting). Exact and substring matches from the original implementation continue to work unchanged.
- **Bulk Model Editing**: Update AI model configuration for multiple tasks at once in the list view. Select tasks via checkboxes (archived tasks excluded), then use the "Bulk Edit Models" toolbar to apply executor and/or validator model changes to all selected tasks. Selection persists in localStorage across page reloads.
- **Task Details**: View full task specifications, agent logs, and attachments. The Agent Log tab expands to fill the full modal body height above the action bar, providing maximum vertical space for watching live agent output. The tab header shows the effective executor and validator model names resolved from task-level overrides or project/global settings fallbacks, matching the same resolution order the engine uses at runtime. A **Markdown/Plain toggle** in the header bar switches between formatted markdown rendering (default) and literal plain-text display — useful for debugging raw agent output, checking escaped markdown syntax, or inspecting exactly what the agent emitted without formatting. The toggle applies to `text` and `thinking` entries only; tool entries always render as plain text. React-markdown handles sanitization in markdown mode (no raw HTML is executed); plain-text mode uses React's built-in text escaping for safe literal output. The refinement modal positions the "Create Refinement Task" button adjacent to the feedback textarea alongside the character count, creating a tight input group that connects the submit action directly to the text being edited. The **Changes** tab for done tasks loads the diff from the recorded merge commit (`mergeDetails.commitSha`) via `fetchCommitDiff` rather than requiring a live worktree — changes remain visible even after the worktree is cleaned up. The tab shows commit metadata (short SHA, merge commit message, merged timestamp) alongside the file-level diff with addition/deletion totals. In-progress and in-review tasks continue to use the worktree-based diff path. Changed-file status indicators (added, modified, deleted, unknown) use semantic CSS classes and theme-aware color variables, ensuring readable contrast across all dashboard themes and light/dark modes.
- **Task Details**: View full task specifications, agent logs, and attachments. The task detail modal uses a top-level tab bar with the following tabs: **Definition**, **Logs**, **Changes** (for in-progress/in-review/done tasks), **Comments**, **Model**, and **Workflow** (when workflow steps are configured). **Activity** and **Agent Log** are subviews within the unified **Logs** tab — click Logs, then toggle between Activity (task lifecycle events, default) and Agent Log (live agent output). The Agent Log subview expands to fill the full modal body height above the action bar, providing maximum vertical space for watching live agent output. The Agent Log header shows the effective executor, validator, and planning/triage model names resolved from task-level overrides or project/global settings fallbacks, matching the same resolution order the engine uses at runtime. A **Markdown/Plain toggle** in the Agent Log header switches between formatted markdown rendering (default) and literal plain-text display — useful for debugging raw agent output, checking escaped markdown syntax, or inspecting exactly what the agent emitted without formatting. The toggle applies to `text` and `thinking` entries only; tool entries always render as plain text. React-markdown handles sanitization in markdown mode (no raw HTML is executed); plain-text mode uses React's built-in text escaping for safe literal output. The refinement modal positions the "Create Refinement Task" button adjacent to the feedback textarea alongside the character count, creating a tight input group that connects the submit action directly to the text being edited. The **Changes** tab for done tasks loads the diff from the recorded merge commit (`mergeDetails.commitSha`) via `fetchCommitDiff` rather than requiring a live worktree — changes remain visible even after the worktree is cleaned up. The tab shows commit metadata (short SHA, merge commit message, merged timestamp) alongside the file-level diff with addition/deletion totals. In-progress and in-review tasks continue to use the worktree-based diff path. Changed-file status indicators (added, modified, deleted, unknown) use semantic CSS classes and theme-aware color variables, ensuring readable contrast across all dashboard themes and light/dark modes.
- **Changed Files Viewer**: Click a task card's "files changed" button to open a dedicated diff viewer showing only files changed in that task worktree, with per-file statuses and sidebar navigation. The sidebar file list uses dedicated `changed-files-entry` styling with explicit button resets (no browser-default background/border/font inheritance) and theme-variable-driven colors for text, icons, hover, active, and focus states — ensuring correct rendering across both dark and light modes and all color themes. On mobile (≤768px), the viewer switches to a single-pane flow: the file list and diff are shown one at a time with a back button for navigation between them. The viewer always opens to the file list on mobile, and only switches to the diff view when the user taps a specific file. Pressing Escape on the diff view returns to the file list first; pressing Escape again closes the modal. Loading, error, and empty states use theme-aware styling (including light mode). Diff syntax highlighting (additions, deletions, hunks) adapts to the active theme for correct contrast. Status badges in the sidebar and diff toolbar use semantic CSS classes (e.g., `changed-files-badge--added`) with theme-aware colors for consistent readability across all themes. The board card file count and the changed-files viewer always agree — both use a shared diff-base resolution strategy. When the task has a `baseCommitSha` (captured at worktree creation time) that is still a valid ancestor of the current HEAD, the diff is scoped to only files introduced by that specific task. If `baseCommitSha` is stale or unavailable, the system falls back to a branch merge-base, then to `HEAD~1`. This ensures accurate file counts in shared or recycled worktree scenarios where a broader merge-base would include files from previous tasks.
- **GitHub Import**: Import issues directly from GitHub repositories
- **PR Management**: Create, monitor, and merge pull requests for in-review tasks

View File

@@ -574,8 +574,8 @@ describe("TaskDetailModal", () => {
/>,
);
// Click on Activity tab to show activity list
fireEvent.click(screen.getByText("Activity"));
// Click Logs tab — Activity is the default subview
fireEvent.click(screen.getByText("Logs"));
const activityList = container.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy();
@@ -657,17 +657,27 @@ describe("TaskDetailModal", () => {
);
expect(screen.getByText("Definition")).toBeTruthy();
expect(screen.getByText("Activity")).toBeTruthy();
expect(screen.getByText("Agent Log")).toBeTruthy();
expect(screen.getByText("Logs")).toBeTruthy();
// Activity and Agent Log are subviews inside the Logs tab, not top-level tabs
// They should NOT be visible on the Definition tab
expect(screen.queryByText("Activity")).toBeNull();
expect(screen.queryByText("Agent Log")).toBeNull();
// Definition content should be visible
expect(container.querySelector(".markdown-body")).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");
});
it("switches to Activity tab and shows activity feed", () => {
it("switches to Activity subview via Logs tab and shows activity feed", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
@@ -685,8 +695,8 @@ describe("TaskDetailModal", () => {
/>,
);
// Click Activity tab
fireEvent.click(screen.getByText("Activity"));
// Click Logs tab — Activity is the default subview
fireEvent.click(screen.getByText("Logs"));
// Activity section should be visible
expect(container.querySelector(".detail-activity")).toBeTruthy();
@@ -696,7 +706,7 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".markdown-body")).toBeNull();
});
it("activity tab renders log entries correctly", () => {
it("Activity subview renders log entries correctly", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
@@ -715,8 +725,8 @@ describe("TaskDetailModal", () => {
/>,
);
// Click Activity tab
fireEvent.click(screen.getByText("Activity"));
// Click Logs tab — Activity is the default subview
fireEvent.click(screen.getByText("Logs"));
const activityList = container.querySelector(".detail-activity-list");
expect(activityList).toBeTruthy();
@@ -732,7 +742,7 @@ describe("TaskDetailModal", () => {
expect(logEntries[2].textContent).toContain("Created task");
});
it("activity tab shows empty state when no logs", () => {
it("Activity subview shows empty state when no logs", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ log: [] })}
@@ -745,8 +755,8 @@ describe("TaskDetailModal", () => {
/>,
);
// Click Activity tab
fireEvent.click(screen.getByText("Activity"));
// Click Logs tab — Activity is the default subview
fireEvent.click(screen.getByText("Logs"));
// Activity section should be visible
expect(container.querySelector(".detail-activity")).toBeTruthy();
@@ -757,7 +767,7 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".detail-activity-list")).toBeNull();
});
it("can switch between all tabs", () => {
it("can switch between all tabs and Logs subviews", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
@@ -777,20 +787,25 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".markdown-body")).toBeTruthy();
expect(container.querySelector(".detail-activity")).toBeNull();
// Switch to Activity tab
fireEvent.click(screen.getByText("Activity"));
// Switch to Logs tab (Activity subview is default)
fireEvent.click(screen.getByText("Logs"));
expect(container.querySelector(".detail-activity")).toBeTruthy();
expect(container.querySelector(".markdown-body")).toBeNull();
// Switch to Agent Log tab
// Switch to Agent Log subview within Logs tab
fireEvent.click(screen.getByText("Agent Log"));
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(screen.getByText("Activity"));
expect(container.querySelector(".detail-activity")).toBeTruthy();
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
// Switch to Comments tab
fireEvent.click(screen.getByText("Comments"));
expect(screen.getByPlaceholderText(/Add a comment/)).toBeTruthy();
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
expect(container.querySelector(".detail-activity")).toBeNull();
// Switch back to Definition tab
fireEvent.click(screen.getByText("Definition"));
@@ -799,7 +814,7 @@ describe("TaskDetailModal", () => {
});
it("switches to Agent Log tab and back", async () => {
it("switches to Agent Log subview via Logs tab and back", async () => {
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
const mockUseAgentLogs = vi.mocked(useAgentLogs);
@@ -815,7 +830,8 @@ describe("TaskDetailModal", () => {
/>,
);
// Click Agent Log tab
// Click Logs tab, then Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
// Agent log viewer should appear
@@ -831,7 +847,7 @@ describe("TaskDetailModal", () => {
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
});
it("passes enabled=true to useAgentLogs only when Agent Log tab is active", async () => {
it("passes enabled=true to useAgentLogs only when Logs → Agent Log subview is active", async () => {
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
const mockUseAgentLogs = vi.mocked(useAgentLogs);
mockUseAgentLogs.mockClear();
@@ -852,11 +868,15 @@ describe("TaskDetailModal", () => {
const initialCall = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(initialCall[1]).toBe(false);
// Switch to Agent Log tab
fireEvent.click(screen.getByText("Agent Log"));
// Switch to Logs tab (Activity subview is default) — enabled should still be false
fireEvent.click(screen.getByText("Logs"));
const afterLogsClick = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(afterLogsClick[1]).toBe(false);
const afterSwitch = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(afterSwitch[1]).toBe(true);
// Switch to Agent Log subview — enabled should become true
fireEvent.click(screen.getByText("Agent Log"));
const afterAgentLog = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(afterAgentLog[1]).toBe(true);
});
it("switches to Comments tab", async () => {
@@ -883,7 +903,7 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".markdown-body")).toBeNull();
});
it("shows Comments tab in tab list", async () => {
it("shows correct top-level tabs including Logs", async () => {
render(
<TaskDetailModal
task={makeTask()}
@@ -896,17 +916,23 @@ describe("TaskDetailModal", () => {
/>,
);
// For an in-progress task, the top-level tabs are:
// Definition, Logs, Changes, Comments, Model (no Activity/Agent Log as top-level)
const tabTexts = ["Definition", "Logs", "Changes", "Comments", "Model"];
const tabs = screen.getAllByRole("button").filter((b) =>
["Definition", "Activity", "Agent Log", "Comments"].includes(b.textContent || "")
tabTexts.includes(b.textContent || "")
);
expect(tabs.length).toBe(4);
expect(tabs[1].textContent).toBe("Activity");
expect(tabs.length).toBe(5);
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Model");
});
});
describe("Agent Log full-height layout", () => {
it("applies detail-body--agent-log class when Agent Log tab is active", () => {
it("applies detail-body--agent-log class when Logs → Agent Log subview is active", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
@@ -922,7 +948,10 @@ describe("TaskDetailModal", () => {
// Initially, detail-body should NOT have the agent-log modifier
expect(container.querySelector(".detail-body--agent-log")).toBeNull();
// Switch to Agent Log tab
// Switch to Logs tab, then Agent Log subview
fireEvent.click(screen.getByText("Logs"));
expect(container.querySelector(".detail-body--agent-log")).toBeNull(); // Activity subview default
fireEvent.click(screen.getByText("Agent Log"));
// detail-body should now have the agent-log modifier class
@@ -948,7 +977,8 @@ describe("TaskDetailModal", () => {
/>,
);
// Switch to Agent Log tab
// Switch to Logs tab, then Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
// The section wrapping AgentLogViewer should have the full-height class
@@ -970,7 +1000,8 @@ describe("TaskDetailModal", () => {
/>,
);
// Switch to Agent Log tab first
// Switch to Logs tab, then Agent Log subview first
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
expect(container.querySelector(".detail-body--agent-log")).toBeTruthy();
@@ -1054,6 +1085,7 @@ describe("TaskDetailModal", () => {
defaultModelId: "claude-sonnet-4-5",
});
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
@@ -1075,6 +1107,7 @@ describe("TaskDetailModal", () => {
validatorModelId: "gpt-4o",
});
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
@@ -1097,6 +1130,7 @@ describe("TaskDetailModal", () => {
// No validatorProvider or validatorModelId
});
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
@@ -1106,10 +1140,10 @@ describe("TaskDetailModal", () => {
expect(header!.textContent).toContain("anthropic/claude-sonnet-4-5");
});
// Count occurrences - should appear twice (once for executor, once for validator)
// Count occurrences - should appear three times (once for executor, once for validator, once for planning)
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
const matches = header.textContent!.match(/anthropic\/claude-sonnet-4-5/g);
expect(matches).toHaveLength(2);
expect(matches).toHaveLength(3);
});
it("shows task executor override even when settings provide a default", async () => {
@@ -1118,6 +1152,7 @@ describe("TaskDetailModal", () => {
{ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" },
);
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
@@ -1140,6 +1175,7 @@ describe("TaskDetailModal", () => {
{ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" },
);
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
@@ -1161,6 +1197,7 @@ describe("TaskDetailModal", () => {
// No defaultProvider/defaultModelId
});
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
@@ -1170,9 +1207,9 @@ describe("TaskDetailModal", () => {
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
expect(header.textContent).toContain("Using default");
// Should show "Using default" for both executor and validator
// Should show "Using default" for executor, validator, and planning
const defaultBadges = header.querySelectorAll(".model-badge-default");
expect(defaultBadges).toHaveLength(2);
expect(defaultBadges).toHaveLength(3);
});
it("shows 'Using default' for both when settings fetch fails", async () => {
@@ -1198,6 +1235,7 @@ describe("TaskDetailModal", () => {
/>,
);
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
// Wait for the failed fetch to settle
@@ -1209,7 +1247,7 @@ describe("TaskDetailModal", () => {
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
expect(header.textContent).toContain("Using default");
const defaultBadges = header.querySelectorAll(".model-badge-default");
expect(defaultBadges).toHaveLength(2);
expect(defaultBadges).toHaveLength(3);
});
it("shows partial override: task executor with settings-based validator", async () => {
@@ -1227,6 +1265,7 @@ describe("TaskDetailModal", () => {
},
);
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
@@ -1626,7 +1665,8 @@ describe("TaskDetailModal", () => {
// Should be visible in Definition tab
expect(container.querySelector(".detail-step-progress")).toBeTruthy();
// Switch to Agent Log tab
// Switch to Logs tab, then Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
// Should not be visible in Agent Log tab
@@ -1729,7 +1769,7 @@ describe("TaskDetailModal", () => {
);
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(6); // Definition, Activity, Agent Log, Changes, Comments, Model
expect(tabs.length).toBe(5); // Definition, Logs, Changes, Comments, Model
// Tabs should use class-based styling, not inline styles
expect(tabs[0].classList.contains("detail-tab")).toBe(true);
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); // Definition is default active
@@ -1737,7 +1777,6 @@ describe("TaskDetailModal", () => {
expect(tabs[2].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[3].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[4].classList.contains("detail-tab-active")).toBe(false);
expect(tabs[5].classList.contains("detail-tab-active")).toBe(false);
// Verify no inline padding/fontSize (responsive CSS controls this)
expect((tabs[0] as HTMLElement).style.padding).toBe("");
expect((tabs[0] as HTMLElement).style.fontSize).toBe("");
@@ -2229,13 +2268,12 @@ describe("TaskDetailModal", () => {
);
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(6); // Definition, Activity, Agent Log, Changes, Comments, Model (in-progress shows Changes)
expect(tabs.length).toBe(5); // Definition, Logs, Changes, Comments, Model ( for in-progress tasks
expect(tabs[0].textContent).toBe("Definition");
expect(tabs[1].textContent).toBe("Activity");
expect(tabs[2].textContent).toBe("Agent Log");
expect(tabs[3].textContent).toBe("Changes");
expect(tabs[4].textContent).toBe("Comments");
expect(tabs[5].textContent).toBe("Model");
expect(tabs[1].textContent).toBe("Logs");
expect(tabs[2].textContent).toBe("Changes");
expect(tabs[3].textContent).toBe("Comments");
expect(tabs[4].textContent).toBe("Model");
});
it("shows empty state and Edit button when no prompt", () => {