diff --git a/client/src/components/lanes/FileExplorer.tsx b/client/src/components/lanes/FileExplorer.tsx index c85a82e4..35bf3f7e 100644 --- a/client/src/components/lanes/FileExplorer.tsx +++ b/client/src/components/lanes/FileExplorer.tsx @@ -369,16 +369,21 @@ export function FileBrowser({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [laneId, initialHome, session, source, initialPath]); - useEffect(() => { + // Re-read after every change here too: deleting a workspace's folder takes + // it out of the places (a workspace whose folder is gone has nothing to show). + const loadLanes = useCallback(() => { api.lanes .list() .then((r) => setLanes(r.lanes)) .catch(() => setLanes([])); + }, []); + useEffect(() => { + loadLanes(); api.files .homes() .then((r) => setHomes(r.homes)) .catch(() => setHomes([])); - }, []); + }, [loadLanes]); // Another user's home (an admin's view) is read-only. const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly; // Clone into the open folder: the absolute path of a workspace's own folder @@ -439,6 +444,8 @@ export function FileBrowser({ } else if (view === "trash") setTrash((await api.files.trash(target)).items); else setListing(await api.files.list(target, path)); } catch (e) { + // Never leave the last folder's entries under another folder's error. + if (view === "files") setListing(null); // A dashboard that does not know /fs yet (not restarted) answers 404. setError( (e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e) @@ -455,6 +462,7 @@ export function FileBrowser({ const changed = () => { void load(); + loadLanes(); onChanged?.(); }; @@ -932,11 +940,18 @@ export function FileBrowser({ // owner whose home this viewer may not open (an analyst's view of other // users) still gets a row, one that only groups. A workspace with no owner // stays in the flat Workspaces list. Which rows are open is per browser. + // A workspace whose folder was deleted has nothing to browse — it stays a + // place only while it is the one open (so its error has a row to sit on). + const placeLanes = useMemo( + () => + lanes.filter((l) => !l.folder_missing || (!isHomePlace(target) && laneOf(target) === l.id)), + [lanes, target] + ); const { lanesOfHome, userRows } = useMemo(() => { const ids = new Set(homes.map((h) => h.id)); const map = new Map(); const extra = new Map(); - for (const l of lanes) { + for (const l of placeLanes) { const home = myId != null && l.owner_id === myId && ids.has("me") ? "me" @@ -959,9 +974,9 @@ export function FileBrowser({ ...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)), ]; return { lanesOfHome: map, userRows: rows }; - }, [homes, lanes, myId]); + }, [homes, placeLanes, myId]); const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id)); - const looseLanes = lanes.filter((l) => !grouped.has(l.id)); + const looseLanes = placeLanes.filter((l) => !grouped.has(l.id)); // A home holding the open workspace shows it, whatever was stored. const homeIsOpen = (id: string, own: Lane[]) => openHomes.has(id) || @@ -1687,9 +1702,7 @@ export function FileBrowser({ )} {isHtml(menuItem) && menuTargets.length === 1 && ( - download([menuItem]))}> - {t("fx.download")} - + download([menuItem]))}>{t("fx.download")} )} {!ro && ( diff --git a/client/src/components/lanes/WorkspaceRow.tsx b/client/src/components/lanes/WorkspaceRow.tsx index 3d8495ce..7bef583b 100644 --- a/client/src/components/lanes/WorkspaceRow.tsx +++ b/client/src/components/lanes/WorkspaceRow.tsx @@ -161,12 +161,19 @@ export function WorkspaceRow({ e.preventDefault(); onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1)); }} - title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`} + title={`${lane.cwd}\n${lane.folder_missing ? t("row.folderMissing") : (readOnly ?? t("row.hint"))}`} className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left" > {t(`row.state.${workspaceDotLabel(lane)}`)} - {name} + + {name} + )} {readOnly && } diff --git a/client/src/components/lanes/__tests__/FileExplorer.test.tsx b/client/src/components/lanes/__tests__/FileExplorer.test.tsx index fde3e3ad..7ed805bd 100644 --- a/client/src/components/lanes/__tests__/FileExplorer.test.tsx +++ b/client/src/components/lanes/__tests__/FileExplorer.test.tsx @@ -459,6 +459,33 @@ describe("FileExplorer", () => { ); }); + it("leaves a workspace whose folder was deleted out of the places", async () => { + vi.mocked(api.lanes.list).mockResolvedValue({ + lanes: [ + { id: 1, title: "one", cwd: "/w/one" }, + { id: 2, title: "two", cwd: "/w/two", folder_missing: true }, + ], + counts: {}, + } as never); + open(); + await screen.findByText("a.txt"); + await waitFor(() => expect(api.lanes.list).toHaveBeenCalled()); + await screen.findByText("one", { selector: "aside span" }); + expect(screen.queryByRole("button", { name: "two" })).toBeNull(); + }); + + it("drops the last folder's entries when the next one cannot be read", async () => { + vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => { + if (p === "src") throw new Error("ENOENT: no such file or directory"); + return root; + }); + open(); + await screen.findByText("a.txt"); + fireEvent.doubleClick(row("src")); + await screen.findByText(/ENOENT/); + expect(screen.queryByText("a.txt")).toBeNull(); + }); + it("right-clicks a sidebar workspace and a path crumb", async () => { const user = userEvent.setup(); open(); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 963f12ee..cb5135fb 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -343,6 +343,7 @@ "changes.takeTheirsHint": "Take the incoming version of the whole file", "row.actions": "Workspace actions", "row.hint": "Double-click to rename · drag or Alt+↑/↓ to reorder · right-click for actions", + "row.folderMissing": "Folder deleted — remove this workspace from its ⋯ menu", "menu.rename": "Rename", "menu.newHere": "New workspace here…", "menu.copyPath": "Copy path", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index b17dfdf2..83109af9 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -343,6 +343,7 @@ "changes.takeTheirsHint": "Lấy bản kia cho cả file", "row.actions": "Thao tác workspace", "row.hint": "Nhấp đúp để đổi tên · kéo hoặc Alt+↑/↓ để sắp xếp · chuột phải để xem thao tác", + "row.folderMissing": "Thư mục đã bị xóa — gỡ workspace này trong menu ⋯", "menu.rename": "Đổi tên", "menu.newHere": "Workspace mới tại đây…", "menu.copyPath": "Sao chép đường dẫn", diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index b824472f..50a5c4ba 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -2731,6 +2731,8 @@ export interface Lane { stage_seconds: number | null; last_event_seconds: number | null; liveness: "active" | "idle" | "dead"; + /** The workspace's folder no longer exists (deleted from Files or a shell). */ + folder_missing?: boolean; /** Stage id the server's heuristic infers from ingested tool events, or null * if no signal was seen. Independent of `stage` (the agent's own declaration). */ detected_stage: string | null; diff --git a/client/src/pages/FilesPage.tsx b/client/src/pages/FilesPage.tsx index 7e30fb7a..f3007cc0 100644 --- a/client/src/pages/FilesPage.tsx +++ b/client/src/pages/FilesPage.tsx @@ -50,7 +50,10 @@ export function FilesPage() { // The workspace last shown first: Files reached from a workspace opens // THAT workspace (from PhuNT → AC_SHOP it opened your own home, empty // but for hidden files); the home is the fallback. - const { lanes } = await api.lanes.list().catch(() => ({ lanes: [] })); + // A workspace whose folder was deleted has nothing to open. + const lanes = (await api.lanes.list().catch(() => ({ lanes: [] }))).lanes.filter( + (l) => !l.folder_missing + ); const last = lastWorkspace(); const shown = lanes.find((l) => l.id === last); if (shown) return setStart({ lane: shown.id }); diff --git a/docs/LANES.md b/docs/LANES.md index 5481ff85..d7eaef44 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -1629,6 +1629,8 @@ curl -X POST http://localhost:4820/api/lanes/5/remove \ **Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidentally dropping a workspace's record. It deletes that record only — never the worktree, the branch or the database. +**A workspace whose folder was deleted** (from Files, or a shell) is not removed for you — its record still holds its sessions' history. Every workspace payload carries `folder_missing` (true only when its `cwd` is gone — `ENOENT`/`ENOTDIR`, never a folder the server merely cannot read): the Files page leaves such a workspace out of its places, and the Workspace sidebar shows its name struck through with a hint to remove it from its ⋯ menu. + ## Proof gallery A workspace's proof gallery is a collection of QC screenshots captured during active work, grouped by feature (the same slug from **Per-feature state and archive** above) and phase (e.g. desktop, mobile, e2e, visual). A QC agent running an MCP server like `playwright-mcp` captures screenshots to disk at `/.playwright-mcp/proof///` and generates an optional ticket report at `/ticket/REPORT.html`. The Workspace gallery panel displays these files grouped by feature, using the same feature picker as the per-feature state section. diff --git a/server/__tests__/lanes-lib.test.js b/server/__tests__/lanes-lib.test.js index 9e14f6ac..07edcfff 100644 --- a/server/__tests__/lanes-lib.test.js +++ b/server/__tests__/lanes-lib.test.js @@ -337,6 +337,16 @@ describe("lanes lib", () => { ); }); + it("payload says folder_missing only when the folder is gone", () => { + const fs = require("node:fs"); + const dir = fs.mkdtempSync(pathMod.join(os.tmpdir(), "lane-gone-")); + const l = lanes.createLane({ cwd: dir }); + assert.equal(lanes.lanePayload(lanes.getLane(l.id)).folder_missing, false); + fs.rmSync(dir, { recursive: true }); + assert.equal(lanes.lanePayload(lanes.getLane(l.id)).folder_missing, true); + lanes.deleteLane(l.id); + }); + it("payload carries node states and progress", () => { const l = lanes.createLane({ cwd: "/tmp/wt/d" }); lanes.setStage(l.id, { stage: "review" }); diff --git a/server/lib/lanes.js b/server/lib/lanes.js index f96661b2..7956e081 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -9,6 +9,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ +const fs = require("node:fs"); const path = require("node:path"); const { db } = require("../db"); const aiTools = require("./ai-tools"); @@ -749,6 +750,17 @@ function ownerNames(id) { }; } +/** True only when the folder is GONE (ENOENT/ENOTDIR) — a folder this + * process may not read is still there, so it never counts as deleted. */ +function folderMissing(cwd) { + try { + fs.statSync(cwd); + return false; + } catch (err) { + return err.code === "ENOENT" || err.code === "ENOTDIR"; + } +} + function lanePayload(lane, ageSec = null) { const pipeline = getPipeline(lane.pipeline); const since = lane.stage_since ? Date.parse(lane.stage_since) : NaN; @@ -775,6 +787,9 @@ function lanePayload(lane, ageSec = null) { : Math.max(0, Math.round((Date.now() - since) / 1000)), last_event_seconds: ageSec, liveness: classifyLiveness({ status: lane.status, stage: lane.stage, ageSec }, DEAD_SEC), + // The folder was deleted under the workspace (from Files, a shell): the + // record stays — its sessions are history — but nothing can open in it. + folder_missing: folderMissing(lane.cwd), }; }