Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
id: BACK-633
title: Show and edit modified files in the web task modal
status: Done
assignee:
- '@Claude'
created_date: '2026-08-15 13:12'
updated_date: '2026-08-15 13:26'
labels: []
dependencies: []
references:
- 'https://github.com/MrLesk/Backlog.md/pull/913'
type: feature
ordinal: 269000
---

## Description

<!-- SECTION:DESCRIPTION:BEGIN -->
The `modifiedFiles` field shipped in BACK-412 (#607) and is wired through the markdown parser/serializer, `backlog task edit --modified-files`, plain and JSON output, the TUI task viewer, the MCP tools, and web search. The web task details modal is the one surface that never adopted it: a task found in the browser by a `modifiedfiles:` search query opened with no trace of the files that matched it, and there was no way to add or remove a path from the browser.

Closing this parity gap means the browser shows the same task model the CLI and TUI already show, and edits go through the existing `PUT /api/tasks/:id` path that already accepts `modifiedFiles`.

The section must stay usable for real-world task sizes. A completed task can list well over a hundred paths, and paths can be long enough to overflow their row, so an unbounded list would push the rest of the modal (documentation, acceptance criteria, plan, notes, comments) out of reach and risk horizontal overflow.

Contributed by @ivan812205 in PR #913.
<!-- SECTION:DESCRIPTION:END -->

## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The web task details modal shows a Modified files section listing the task's modified file paths
- [x] #2 A path can be added and removed from the modal, and the change persists through the existing task update API
- [x] #3 The section stays usable with 100+ paths: its list is height-bounded and scrolls internally instead of pushing later modal sections out of reach
- [x] #4 Long paths wrap within their row so neither the modal nor the page scrolls horizontally
- [x] #5 Read-only cross-branch tasks show the paths without add or remove controls
- [x] #6 A web test covers rendering a task with many long modified file paths
<!-- AC:END -->

## Definition of Done
<!-- DOD:BEGIN -->
- [x] #1 bunx tsc --noEmit passes when TypeScript touched
- [x] #2 bun run check . passes when formatting/linting touched
- [x] #3 bun test (or scoped test) passes
<!-- DOD:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->
1. Keep @ivan812205's Modified files section (state wiring, refresh preservation, inline add/remove) as the base; it already matches the References pattern and the server already accepts modifiedFiles on PUT /api/tasks/:id.
2. Bound the list so 100+ paths cannot push the rest of the modal out of reach: apply the repo's existing max-h-64 overflow-y-auto pattern (CleanupModal, DuplicateIdRepairModal) to the paths list, with overscroll-contain so the inner scroll does not chain to the modal.
3. Show the path count in the section heading, following the existing Comments (n) SectionHeader precedent, so a long list announces its size without helper text.
4. Keep break-all wrapping from the References section so long paths wrap in place and never cause horizontal overflow; align the remove control to the first line so multi-line paths keep a predictable hit target.
5. Add hasCreateModeEntries parity for modifiedFiles alongside references.
6. Add src/test/web-task-details-modal-modified-files.test.tsx following web-task-details-modal-documentation.test.tsx: render a task with many long paths and assert every path renders inside a bounded scrolling list, plus cross-branch read-only behavior.
7. Verify visually against a throwaway project seeded with 120+ long paths; run tsc, biome, full test suite, and build.
<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->
Took over @ivan812205's PR #913 and kept their commit as the base. Their diff was already correct on the parts that are easy to get wrong: the state hook, the form-state snapshot, the dirty-preserving refresh, the task-switch reset, the external-update sync, and routing both mutations through handleInlineMetaUpdate. No server change was needed - PUT /api/tasks/:id already applies modifiedFiles (src/server/index.ts) and core normalizes it (src/core/backlog.ts resolveModifiedFiles). handleSave deliberately does not send modifiedFiles, exactly like references, so a Save in edit mode cannot clobber inline edits.

Hardening added on top, for the many-files case:
- The paths list is capped at max-h-64 with overflow-y-auto and overscroll-contain, following the CleanupModal/DuplicateIdRepairModal bounded-list pattern. Measured against a seeded task with 130 long paths: the list content is 10392px but renders in a 256px box, so the section is 372px instead of 10508px and the modal is 1339px instead of 11475px (8.6x). Every path stays in the DOM and the last one is reachable by scrolling; overscroll-contain keeps the inner wheel from scrolling the modal behind it. The cap is inert for ordinary tasks: a 3-path task measures an 88px list with no scrollbar.
- The heading carries the count ("Modified files (130)"), matching the existing Comments (n) SectionHeader precedent, so a collapsed list still announces its size without helper text.
- Rows switched from items-center to items-start with mt-0.5 on the remove button, so on a path that wraps to 3 lines the control sits within 3px of the first line instead of floating in the middle of a 72px row.
- hasCreateModeEntries now counts modifiedFiles alongside references.

Long paths keep the References break-all wrapping rather than truncating, so nothing is hidden. Verified no horizontal overflow at 1280px and at 375px (mobile: paths wrap to 6 lines, section still 372px, documentElement.scrollWidth == clientWidth on both).

Edit path verified end to end in a throwaway project, not just in tests: adding a path from the modal wrote it into the task markdown and CLI task view --json reported 131; removing it from the modal brought both back to 130.

Known pre-existing behavior, not introduced here: pressing Enter in the add input did not submit under CDP-synthesized keys. The References add form behaves identically, so this is shipped parity rather than a regression; the Add button works in both.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added the Modified files section to the web task details modal, closing the last surface gap for the modifiedFiles field shipped in BACK-412: the browser now shows the paths a task touched and lets them be added or removed inline, through the PUT /api/tasks/:id path that already accepted the field. Built on @ivan812205's PR #913, whose state wiring and refresh-preservation already matched the sibling References section.

Hardened for real task sizes: the paths list is height-bounded and scrolls internally, the heading carries the count, and rows align their remove control to the first line of a wrapped path. Measured on a seeded 130-path task with long paths: the section renders at 372px instead of 10508px and the modal at 1339px instead of 11475px, with all 130 paths still present and reachable, no horizontal overflow at 1280px or 375px, and no visible change for ordinary short lists (88px, no scrollbar).

Verified with a new SSR test (src/test/web-task-details-modal-modified-files.test.tsx, 5 cases including a 120-path long-path case and cross-branch read-only, confirmed to fail when the height cap is removed) and a live browser round-trip in a throwaway project where a path added from the modal appeared in the task markdown and in CLI task view --json, then disappeared from both when removed. Gates: bunx tsc --noEmit clean, bun run check . clean, bun run test 2250 pass / 6 skip / 0 fail, bun run build succeeds.
<!-- SECTION:FINAL_SUMMARY:END -->
106 changes: 106 additions & 0 deletions src/test/web-task-details-modal-modified-files.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it } from "bun:test";
import { JSDOM } from "jsdom";
import { renderToString } from "react-dom/server";
import type { Task } from "../types/index.ts";
import { TaskDetailsModal } from "../web/components/TaskDetailsModal";
import { ThemeProvider } from "../web/contexts/ThemeContext";

const setupDom = () => {
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost" });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document as Document;
globalThis.navigator = dom.window.navigator as Navigator;
globalThis.localStorage = dom.window.localStorage;

if (!window.matchMedia) {
window.matchMedia = () =>
({
matches: false,
media: "",
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList;
}
};

const baseTask = (overrides: Partial<Task>): Task => ({
id: "TASK-1",
title: "Task with modified files",
status: "To Do",
assignee: [],
createdDate: "2025-01-01",
labels: [],
dependencies: [],
...overrides,
});

const renderModal = (task: Task) => {
setupDom();
return renderToString(
<ThemeProvider>
<TaskDetailsModal task={task} isOpen={true} onClose={() => {}} />
</ThemeProvider>,
);
};

// A path long enough to overflow its row, so wrapping rather than horizontal scrolling is what keeps it readable.
const longPath = (index: number) =>
`src/web/components/${`deeply-nested-feature-directory-${index}/`.repeat(6)}TaskDetailsModalSection${index}.tsx`;

describe("Web task popup modified files display", () => {
it("renders modified file paths when present", () => {
const html = renderModal(baseTask({ modifiedFiles: ["src/cli.ts", "src/web/components/TaskDetailsModal.tsx"] }));

expect(html).toContain("Modified files");
expect(html).toContain("src/cli.ts");
expect(html).toContain("src/web/components/TaskDetailsModal.tsx");
});

it("shows an empty state when the task has no modified files", () => {
const html = renderModal(baseTask({ modifiedFiles: [] }));

expect(html).toContain("Modified files");
expect(html).toContain("No modified files");
});

it("counts the paths in the section heading", () => {
const html = renderModal(baseTask({ modifiedFiles: ["src/cli.ts", "src/server/index.ts", "src/types/index.ts"] }));

expect(html).toContain("Modified files (3)");
});

it("keeps the modal usable when a task lists many long paths", () => {
const modifiedFiles = Array.from({ length: 120 }, (_, index) => longPath(index));
const html = renderModal(baseTask({ modifiedFiles }));

// Every path stays in the document: the list is bounded by scrolling, not by dropping entries.
for (const file of modifiedFiles) {
expect(html).toContain(file);
}
expect(html).toContain("Modified files (120)");

// The list scrolls inside its own section so the sections below it stay reachable.
const section = html.slice(html.indexOf("Modified files (120)"));
const listClasses = section.match(/<ul class="([^"]*)"/)?.[1] ?? "";
expect(listClasses).toContain("max-h-64");
expect(listClasses).toContain("overflow-y-auto");

// Long paths break inside their row instead of widening the modal.
expect(html).toContain("break-all");

// Sections rendered after the modified files list are still part of the modal.
expect(html).toContain("Acceptance Criteria");
});

it("hides add and remove controls for read-only cross-branch tasks", () => {
const html = renderModal(baseTask({ branch: "feature/other", modifiedFiles: ["src/cli.ts"] }));

expect(html).toContain("src/cli.ts");
expect(html).not.toContain("Remove modified file");
expect(html).not.toContain("newModifiedFile");
});
});
81 changes: 80 additions & 1 deletion src/web/components/TaskDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ type TaskDetailsFormState = {
taskType: string;
dependencies: string[];
references: string[];
modifiedFiles: string[];
milestone: string;
};

Expand Down Expand Up @@ -114,6 +115,7 @@ const buildTaskDetailsFormState = ({
taskType: task?.type || "",
dependencies: task?.dependencies || [],
references: task?.references || [],
modifiedFiles: task?.modifiedFiles || [],
milestone: task?.milestone || "",
});

Expand Down Expand Up @@ -331,6 +333,7 @@ export const TaskDetailsModal: React.FC<Props> = ({
const typeUpdateRequestRef = useRef(0);
const [dependencies, setDependencies] = useState<string[]>(task?.dependencies || []);
const [references, setReferences] = useState<string[]>(task?.references || []);
const [modifiedFiles, setModifiedFiles] = useState<string[]>(task?.modifiedFiles || []);
const [milestone, setMilestone] = useState<string>(task?.milestone || "");
const canonicalTypeSelection = resolveTaskTypeValue(taskType, typeOptions);
const typeSelectionValue = canonicalTypeSelection ?? taskType;
Expand Down Expand Up @@ -505,6 +508,14 @@ export const TaskDetailsModal: React.FC<Props> = ({
setReferences((current) =>
preserveDirtyRefreshValue(current, previousFormState.references, nextFormState.references, areJsonEqual),
);
setModifiedFiles((current) =>
preserveDirtyRefreshValue(
current,
previousFormState.modifiedFiles,
nextFormState.modifiedFiles,
areJsonEqual,
),
);
setMilestone((current) =>
preserveDirtyRefreshValue(current, previousFormState.milestone, nextFormState.milestone),
);
Expand Down Expand Up @@ -536,6 +547,7 @@ export const TaskDetailsModal: React.FC<Props> = ({
setTaskType(nextFormState.taskType);
setDependencies(nextFormState.dependencies);
setReferences(nextFormState.references);
setModifiedFiles(nextFormState.modifiedFiles);
setMilestone(nextFormState.milestone);
setMode(isCreateMode ? "create" : "preview");
preserveEditModeAfterCommentRefresh.current = false;
Expand Down Expand Up @@ -563,7 +575,8 @@ export const TaskDetailsModal: React.FC<Props> = ({
!areJsonEqual(assignee, createModeAssignee) ||
labels.length > 0 ||
dependencies.length > 0 ||
references.length > 0);
references.length > 0 ||
modifiedFiles.length > 0);
const hasUnsavedEdits =
(mode === "edit" || mode === "create") && (isDirty || hasCommentDraft || hasCreateModeEntries);

Expand Down Expand Up @@ -815,6 +828,7 @@ export const TaskDetailsModal: React.FC<Props> = ({
if (updates.type !== undefined) setTaskType(String(updates.type));
if (updates.dependencies !== undefined) setDependencies(updates.dependencies as string[]);
if (updates.references !== undefined) setReferences(updates.references as string[]);
if (updates.modifiedFiles !== undefined) setModifiedFiles(updates.modifiedFiles as string[]);
if (updates.milestone !== undefined) setMilestone((updates.milestone ?? "") as string);

// Only update server if editing existing task
Expand Down Expand Up @@ -1127,6 +1141,71 @@ export const TaskDetailsModal: React.FC<Props> = ({
</div>
</div>

{/* Modified files */}
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<SectionHeader title={`Modified files${modifiedFiles.length ? ` (${modifiedFiles.length})` : ""}`} />
<div className="space-y-3">
{modifiedFiles.length > 0 ? (
// A finished task can list hundreds of paths, so the list scrolls inside the
// section instead of pushing the sections below it out of reach.
<ul className="space-y-2 max-h-64 overflow-y-auto overscroll-contain pr-1">
{modifiedFiles.map((file, idx) => (
<li key={idx} className="flex items-start gap-3 group">
<span className="flex-1 min-w-0">
<code className="text-sm font-mono text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded break-all">
{file}
</code>
</span>
{!isFromOtherBranch && (
<button
onClick={() => {
const newFiles = modifiedFiles.filter((_, i) => i !== idx);
handleInlineMetaUpdate({ modifiedFiles: newFiles });
}}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all flex-shrink-0 mt-0.5"
title="Remove modified file"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">No modified files</p>
)}
{mode === "preview" && !isFromOtherBranch && (
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem("newModifiedFile") as HTMLInputElement;
const value = input.value.trim();
if (value && !modifiedFiles.includes(value)) {
handleInlineMetaUpdate({ modifiedFiles: [...modifiedFiles, value] });
input.value = "";
}
}}
className="flex gap-2"
>
<input
name="newModifiedFile"
type="text"
placeholder="Path from project root..."
className="flex-1 text-sm px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
/>
<button
type="submit"
className="px-4 py-2 text-sm font-medium bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Add
</button>
</form>
)}
</div>
</div>

{/* Documentation */}
{documentation.length > 0 && (
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
Expand Down