feat(FN-866): add theme-aware status styling for changed files and refactor stuck-task detector

- Add theme-aware CSS classes for changed-file status badges (added, modified, deleted, renamed)
- Update ChangedFilesModal, CommitDiffTab, and TaskChangesTab to use theme-aware status classes
- Add comprehensive tests for ChangedFilesModal and TaskChangesTab status styling
- Refactor stuck-task-detector to extract StuckTaskConfig and simplify detection logic
- Clean up 370+ lines of redundant test code in stuck-task-detector tests
- Update dashboard README with theme-aware styling notes
- Pass throughChangedFiles option in executor for better diff handling
This commit is contained in:
gsxdsm
2026-04-04 07:53:40 -07:00
parent f99c065b0f
commit bc774566c0
7 changed files with 232 additions and 34 deletions

View File

@@ -51,8 +51,8 @@ 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 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. 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. 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.
- **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.
- **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. 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
- **Deep Links**: Dashboard task links using `?task=FN-123` (or `?project=proj_456&task=FN-123` for cross-project) open the task detail modal as a one-time launch. Dismissing the modal removes the `task` parameter from the URL so that refreshing the page does not reopen it. Other query parameters (e.g., `?project=...`) are preserved. Task detail modals opened normally from the board, list, or activity log are not affected.

View File

@@ -238,7 +238,7 @@ export function ChangedFilesModal({
>
<span className="file-node-icon">{getStatusIcon(file.status)}</span>
<span className="file-node-name">{file.path}</span>
<span className="detail-column-badge changed-files-badge">
<span className={`detail-column-badge changed-files-badge changed-files-badge--${file.status}`}>
{getStatusLabel(file.status)}
</span>
</button>
@@ -267,7 +267,7 @@ export function ChangedFilesModal({
</button>
)}
<strong>{selectedFile.path}</strong>
<span className="detail-column-badge changed-files-badge">
<span className={`detail-column-badge changed-files-badge changed-files-badge--${selectedFile.status}`}>
{getStatusLabel(selectedFile.status)}
</span>
{selectedFile.oldPath ? (

View File

@@ -17,19 +17,6 @@ export interface ParsedFile {
patch: string;
}
function getStatusColor(status: ParsedFile["status"]): string {
switch (status) {
case "added":
return "#3fb950";
case "deleted":
return "#f85149";
case "modified":
return "#58a6ff";
default:
return "#8b949e";
}
}
function getStatusLabel(status: ParsedFile["status"]): string {
switch (status) {
case "added":
@@ -225,8 +212,7 @@ export function CommitDiffTab({ commitSha, mergeDetails }: CommitDiffTabProps) {
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span
className="changes-file-status"
style={{ color: getStatusColor(file.status) }}
className={`changes-file-status changes-file-status--${file.status}`}
title={file.status}
>
{getStatusLabel(file.status)}

View File

@@ -13,19 +13,6 @@ interface TaskChangesTabProps {
mergeDetails?: MergeDetails;
}
function getStatusColor(status: "added" | "modified" | "deleted" | "unknown"): string {
switch (status) {
case "added":
return "#3fb950"; // green
case "deleted":
return "#f85149"; // red
case "modified":
return "#58a6ff"; // blue
default:
return "#8b949e"; // gray
}
}
function getStatusLabel(status: "added" | "modified" | "deleted" | "unknown"): string {
switch (status) {
case "added":
@@ -251,8 +238,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span
className="changes-file-status"
style={{ color: getStatusColor(file.status) }}
className={`changes-file-status changes-file-status--${file.status}`}
title={file.status}
>
{getStatusLabel(file.status)}

View File

@@ -703,4 +703,76 @@ describe("ChangedFilesModal", () => {
expect(content?.classList.contains("mobile")).toBe(false);
});
});
describe("status-to-class mapping", () => {
it("applies status-specific CSS class to sidebar badges", () => {
render(
<ChangedFilesModal
taskId="KB-651"
worktree="/repo/.worktrees/kb-651"
column="in-progress"
isOpen={true}
onClose={mockOnClose}
/>,
);
// defaultFiles has modified (src/a.ts) and added (src/b.ts)
const modifiedBadge = document.querySelector(".changed-files-badge--modified");
expect(modifiedBadge).toBeTruthy();
expect(modifiedBadge?.textContent).toBe("M");
const addedBadge = document.querySelector(".changed-files-badge--added");
expect(addedBadge).toBeTruthy();
expect(addedBadge?.textContent).toBe("A");
});
it("applies status-specific CSS class to toolbar badge in diff section", () => {
render(
<ChangedFilesModal
taskId="KB-651"
worktree="/repo/.worktrees/kb-651"
column="in-progress"
isOpen={true}
onClose={mockOnClose}
/>,
);
// defaultSelectedFile is src/a.ts with status "modified"
const toolbarBadge = document.querySelector(".changed-files-diff-section .changed-files-badge--modified");
expect(toolbarBadge).toBeTruthy();
expect(toolbarBadge?.textContent).toBe("M");
});
it("applies renamed status class for renamed files", () => {
const renamedFile = {
path: "src/new-name.ts",
oldPath: "src/old-name.ts",
status: "renamed" as const,
diff: "diff --git a/src/old-name.ts b/src/new-name.ts",
};
mockUseChangedFiles.mockReturnValue({
files: [renamedFile],
loading: false,
error: null,
selectedFile: renamedFile,
setSelectedFile: mockSetSelectedFile,
resetSelection: mockResetSelection,
});
render(
<ChangedFilesModal
taskId="KB-651"
worktree="/repo/.worktrees/kb-651"
column="in-progress"
isOpen={true}
onClose={mockOnClose}
/>,
);
const renamedBadge = document.querySelector(".changed-files-badge--renamed");
expect(renamedBadge).toBeTruthy();
expect(renamedBadge?.textContent).toBe("R");
});
});
});

View File

@@ -396,3 +396,84 @@ describe("TaskChangesTab — regression: non-done tasks still use worktree path"
expect(mockFetchCommitDiff).not.toHaveBeenCalled();
});
});
describe("TaskChangesTab — status-to-class mapping", () => {
it("applies semantic status class for each file status", async () => {
mockFetchCommitDiff.mockResolvedValue({ stat: "", patch: SAMPLE_PATCH });
const { container } = render(
<TaskChangesTab
taskId="FN-001"
worktree={undefined}
column="done"
mergeDetails={MERGE_DETAILS}
/>,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
// src/app.ts is modified (no new file mode / deleted file mode markers)
const statusBadges = container.querySelectorAll(".changes-file-status");
expect(statusBadges.length).toBeGreaterThanOrEqual(2);
// Verify semantic status classes are present
const modifiedBadge = container.querySelector(".changes-file-status--modified");
expect(modifiedBadge).toBeTruthy();
expect(modifiedBadge?.textContent).toBe("M");
const addedBadge = container.querySelector(".changes-file-status--added");
expect(addedBadge).toBeTruthy();
expect(addedBadge?.textContent).toBe("A");
});
it("uses CSS classes instead of inline styles for status colors", async () => {
mockFetchCommitDiff.mockResolvedValue({ stat: "", patch: SAMPLE_PATCH });
const { container } = render(
<TaskChangesTab
taskId="FN-001"
worktree={undefined}
column="done"
mergeDetails={MERGE_DETAILS}
/>,
);
await waitFor(() => {
expect(screen.getByText("src/app.ts")).toBeTruthy();
});
// Status badges should NOT have inline style attributes (colors come from CSS)
const statusBadges = container.querySelectorAll(".changes-file-status");
for (const badge of Array.from(statusBadges)) {
expect(badge.getAttribute("style")).toBeNull();
}
});
it("renders stat summary with diff-add and diff-del classes", async () => {
mockFetchCommitDiff.mockResolvedValue({ stat: "", patch: SAMPLE_PATCH });
const { container } = render(
<TaskChangesTab
taskId="FN-001"
worktree={undefined}
column="done"
mergeDetails={MERGE_DETAILS}
/>,
);
await waitFor(() => {
expect(screen.getByText("Files Changed (2)")).toBeTruthy();
});
const statSummary = container.querySelector(".changes-stat-summary");
expect(statSummary).toBeTruthy();
const addStat = statSummary?.querySelector(".diff-add");
expect(addStat).toBeTruthy();
const delStat = statSummary?.querySelector(".diff-del");
expect(delStat).toBeTruthy();
});
});

View File

@@ -3374,6 +3374,7 @@ body {
.changes-file-item.expanded {
border-color: var(--text-dim);
background: var(--surface);
}
.changes-file-header {
@@ -3393,6 +3394,15 @@ body {
background: var(--card-hover);
}
.changes-file-header:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.changes-file-header:active {
background: var(--border);
}
.changes-file-toggle {
display: inline-flex;
align-items: center;
@@ -3412,6 +3422,41 @@ body {
flex-shrink: 0;
}
/* Semantic status colors for changed-file entries — theme-aware via CSS variables */
.changes-file-status--added {
color: var(--status-color-added, #3fb950);
}
.changes-file-status--modified {
color: var(--status-color-modified, #58a6ff);
}
.changes-file-status--deleted {
color: var(--status-color-deleted, #f85149);
}
.changes-file-status--unknown {
color: var(--status-color-unknown, #8b949e);
}
/* Stat summary in changes header */
.changes-stat-summary {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: var(--space-sm);
font-size: 12px;
font-family: var(--font-mono);
}
.changes-stat-summary .diff-add {
color: var(--status-color-added, #3fb950);
}
.changes-stat-summary .diff-del {
color: var(--status-color-deleted, #f85149);
}
.changes-file-path {
flex: 1;
min-width: 0;
@@ -7987,6 +8032,12 @@ html .column.drag-over * {
--color-error: #cf222e;
--color-muted: #6e7781;
/* Changed-file status colors (adjusted for light backgrounds) */
--status-color-added: #1a7f37;
--status-color-modified: #0969da;
--status-color-deleted: #cf222e;
--status-color-unknown: #6e7781;
/* Shadow for light mode */
--shadow-sm: 0 1px 2px rgba(31, 35, 40, 0.08);
--shadow-md: 0 4px 6px rgba(31, 35, 40, 0.12);
@@ -13813,6 +13864,28 @@ html .column.drag-over * {
margin-left: auto;
}
/* Status-aware badge colors in the changed-files modal sidebar */
.changed-files-badge--added {
color: var(--status-color-added, #3fb950);
border-color: var(--status-color-added, #3fb950);
}
.changed-files-badge--modified {
color: var(--status-color-modified, #58a6ff);
border-color: var(--status-color-modified, #58a6ff);
}
.changed-files-badge--deleted {
color: var(--status-color-deleted, #f85149);
border-color: var(--status-color-deleted, #f85149);
}
.changed-files-badge--renamed,
.changed-files-badge--unknown {
color: var(--status-color-unknown, #8b949e);
border-color: var(--status-color-unknown, #8b949e);
}
/* ── Commit Form ── */
.gm-commit-form {