Fix HIGH and MEDIUM frontend defects

Files pane
- F16: a drag-out released back inside the app no longer re-imports its own
  staged copy over the container original. An in-flight flag (cleared from the
  drag plugin's `onEvent` channel, with a watchdog) suppresses the drop and the
  "Drop files into …" hint, and an exact staged-path filter is the second line
  of defence — the `path|size|modified` cache could otherwise write a
  minutes-old snapshot over a file an agent had since rewritten.
- F17: a slow upload/rename no longer yanks the user back to the directory the
  operation started in. Every operation captures its target path and re-lists
  only if the user is still there; failures go to the toast host either way.
- The grid keeps keyboard focus. Roving tabindex (one tab stop, not one per
  row) plus focus restore after navigation, rename commit/cancel and Escape.
- Transient failures now surface in `ToastHost` (z-[60], persistent aria-live)
  instead of a `role="alert"` 300 rows down a scroller or behind a modal
  overlay. The inline error is kept only for the listing failure.
- `navigate` is sequenced by generation; "Save to host…" sets `busy`.
- Grid a11y: column headers, a text affordance for folder vs file, a live
  region that is mounted empty and announces completion, Label-in-Name fixed.
- FileViewerModal: the blob URL is released only once its replacement exists;
  the preview is a focusable, named, scrollable region.

Native drop routing
- New `lib/dropTarget.ts`: the hit test now refuses a drop while any
  `[aria-modal="true"]` dialog or `[data-blocks-drop]` overlay is up, and
  checks z-order where the environment can answer it. Shared by FilesTab and
  TerminalView; App's shutdown overlay opts in.

Disk
- A partially failed reclaim says so in words ("… — 2 of 5 failed"), not by hue
  alone.
- The scan/reclaim race is closed: every mutation retires an in-flight scan, so
  a scan can no longer repaint a pre-reclaim report plus a clickable plan of
  objects that are gone. Scan is disabled while working; the status is a live
  region; a failed destructive action keeps its dialog open and reports there.
- The "unknown" layer count gets a screen-reader fallback; `--text-disabled`
  no longer carries live information.

Terminal / OAuth
- After the toast is dismissed, a truncated heuristic guess can no longer fill
  the slot that an exact OSC 8 or relay URL occupied — the detector remembers
  every exact URL and drops any candidate that is a strict prefix of one.
- The prompt is reachable by keyboard: Ctrl+Shift+O jumps to the default
  action, Escape dismisses, focus returns to the terminal, and auto-dismiss
  holds off while focus is inside. It deliberately does not steal focus.
- UrlToast renders through `ui/Button` and `--shadow-overlay`.

Elsewhere
- AuthBridgeRow: a pushed `auth-bridge-changed` status always outranks an older
  awaited toggle result.
- The last two ad-hoc byte formatters route through `lib/formatBytes`.

Contract for the backend agent: `upload_file_to_container` refusing to
overwrite must satisfy `isFileExistsError` in `src/lib/uploadErrors.ts` (marker
`FILE_EXISTS`) and accept an `overwrite` argument; the frontend turns that into
an `ui/Modal` Replace/Skip prompt rather than a raw error string.

Tests: 536 -> 627 passing. `npm run build` and `npx tsc --noEmit` green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 11:11:43 -07:00
co-authored by Claude Opus 5
parent 0003793abb
commit d6f065a2b6
33 changed files with 3120 additions and 315 deletions
@@ -6,6 +6,7 @@ import type {
ProjectDiskRow,
ReclaimItem,
ReclaimPlan,
ReclaimResult,
ReclaimTarget,
} from "../../lib/types";
@@ -104,6 +105,16 @@ const item = (over: Partial<ReclaimItem> = {}): ReclaimItem => ({
...over,
});
const result = (over: Partial<ReclaimResult> = {}): ReclaimResult => ({
target: { kind: "dangling_snapshots" },
destroyed: null,
ok: true,
freed_bytes: 0,
projected_bytes: null,
message: "Removed 3 images.",
...over,
});
const plan = (over: Partial<ReclaimPlan> = {}): ReclaimPlan => ({
items: [item()],
destructive: [],
@@ -620,6 +631,209 @@ describe("DiskSettings", () => {
expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Failure has to reach the words, and the place the user is looking
// -------------------------------------------------------------------------
it("puts a partial failure in the headline, not only in the glyph's hue", async () => {
// This panel is where the "never encode status in colour alone" rule is
// documented, and the outcome headline used to say "Reclaimed 1.2 GB" for
// a run where most of the targets threw — only the glyph and its colour
// changed, which is exactly nothing to a screen reader or to anyone who
// does not read red as bad.
reclaim.mockResolvedValue({
results: [
result({ freed_bytes: 1_200_000_000 }),
result({ target: { kind: "migration_pins" } }),
result({ target: { kind: "probe_containers" } }),
result({ target: { kind: "build_cache", all: true }, ok: false }),
result({ target: { kind: "orphan_volume", name: "v" }, ok: false }),
],
total_freed_bytes: 1_200_000_000,
});
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
});
const outcome = await screen.findByTestId("disk-outcome");
expect(
within(outcome).getByText("Reclaimed 1.2 GB — 2 of 5 failed"),
).toBeInTheDocument();
});
it("keeps the plain wording when every target succeeded", async () => {
reclaim.mockResolvedValue({
results: [result({ freed_bytes: 1_200_000_000 }), result()],
total_freed_bytes: 1_200_000_000,
});
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
});
const outcome = await screen.findByTestId("disk-outcome");
expect(within(outcome).getByText("Reclaimed 1.2 GB")).toBeInTheDocument();
expect(outcome.textContent).not.toMatch(/failed/);
});
it("keeps the typed confirmation open, and says why, when the deletion fails", async () => {
// The dialog used to close regardless, leaving the failure in a line at
// the very top of a panel the user had scrolled past to reach the row.
listReclaimable.mockResolvedValue(
plan({
destructive: [
{
target: { kind: "home_volume", project_id: "p-whp" },
project_id: "p-whp",
project_name: "whp",
label: "Home volume",
loses: "Shell history and toolchains.",
bytes: 4_860_000_000,
blocked: null,
},
],
}),
);
destroyProjectDiskObject.mockRejectedValue(
"volume triple-c-home-p-whp is in use by a running container",
);
await renderAndScan();
await screen.findByTestId("disk-row-p-whp");
fireEvent.click(screen.getByRole("button", { name: "Delete whp data" }));
await act(async () => {
fireEvent.click(screen.getByRole("menuitem", { name: /Delete home volume/ }));
});
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } });
await act(async () => {
fireEvent.click(within(dialog).getByRole("button", { name: "Delete home volume" }));
});
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(within(screen.getByRole("dialog")).getByRole("alert")).toHaveTextContent(
/in use by a running container/,
);
});
it("keeps the semi-safe confirmation open when the action fails", async () => {
listReclaimable.mockResolvedValue(
plan({
items: [
item({
target: { kind: "compact_snapshot", project_id: "p-whp" },
safety: "semi_safe",
label: "Compact whp's snapshot",
bytes: 5_100_000_000,
bytes_are_exact: false,
bytes_floor: 0,
}),
],
}),
);
reclaim.mockRejectedValue("compaction failed: no space left on device");
await renderAndScan();
const semi = await screen.findByTestId("disk-semi-bucket");
await act(async () => {
fireEvent.click(within(semi).getByRole("button", { name: "Run…" }));
});
await act(async () => {
fireEvent.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Run it" }),
);
});
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByRole("alert")).toHaveTextContent(/no space left on device/);
});
it("closes the confirmation once the action succeeds", async () => {
listReclaimable.mockResolvedValue(
plan({
items: [
item({
target: { kind: "clear_caches", project_id: "p-whp", include_rustup: false },
safety: "semi_safe",
label: "Clear whp's caches",
}),
],
}),
);
await renderAndScan();
const semi = await screen.findByTestId("disk-semi-bucket");
await act(async () => {
fireEvent.click(within(semi).getByRole("button", { name: "Run…" }));
});
await act(async () => {
fireEvent.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Run it" }),
);
});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Scan status: announced, and not startable mid-mutation
// -------------------------------------------------------------------------
it("announces the scan status through a live region", async () => {
// The status flips between three states with no other signal; without a
// live region wrapping it the change is silent.
render(<DiskSettings />);
const live = screen.getByRole("status");
expect(live).toHaveAttribute("aria-live", "polite");
expect(live).toHaveTextContent("Not scanned");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
});
// The glyph is `aria-hidden` but still part of `textContent`.
expect(screen.getByRole("status")).toHaveTextContent(/Scanned \d/);
});
it("cannot start a scan while a reclaim is still running", async () => {
// A scan launched on top of a mutation measures a daemon that is being
// changed underneath it — the hook can only throw such a result away, so
// the seconds are better not spent.
let finish: (value: unknown) => void = () => {};
reclaim.mockReturnValue(new Promise((r) => (finish = r)));
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Scan again" })).toBeDisabled(),
);
await act(async () => {
finish({ results: [], total_freed_bytes: 0 });
});
expect(screen.getByRole("button", { name: "Scan again" })).toBeEnabled();
});
it("gives the unknown layer count its explanation without a hover", async () => {
// The tooltip portals a div with no role and no `aria-describedby`, and
// wrapped around children it has no focus handlers either — so without the
// sr-only copy "unknown" reads as a bug to everyone not using a mouse.
getDockerDiskUsage.mockResolvedValue(
report({ projects: [row({ base_lineage_known: false, snapshot_commit_layers: 17 })] }),
);
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(projectRow.textContent).toMatch(/predates the base-image label/);
expect(projectRow.textContent).toMatch(/Migrating it to the current base restores the count/);
});
it("surfaces a scan failure as an alert", async () => {
getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host");
render(<DiskSettings />);