Reconcile the frontend with the round-1 backend contracts

Five backend branches merged and the TypeScript still compiled, because
none of this is a type error: a field that arrives `undefined`, a variant
nothing emits any more, a prompt whose loop never closes. Six things.

**Orphaned volumes are destructive now, not safe.** `ReclaimTarget::
OrphanVolume` is gone from Rust; the object is a `DestructiveTarget::
OrphanVolume { name, project_id }` confirmed against the *volume's* name,
there being no project to name. The TS union still listed it under
`ReclaimTarget`, and — worse — `DiskProjectTable` keys destructive items
off `project_id`, which an orphan's never matches. So the item existed in
the plan and appeared nowhere on screen. `DiskSettings` now splits the
plan's destructive list and gives orphans their own section with a
per-volume `TypedConfirmModal`. The copy says what a
`triple-c-claude-config-*` volume actually is — a Claude login
credential, every plugin and skill, every transcript that project had —
and keeps the sentence explaining that "no matching project" is a lookup
against the project list and is never inferred from a project being
stopped or having no image, which is the inference that once flagged two
live projects.

`TypedConfirmModal` grew a `subject` prop: asking a user for "the exact
project name" of a volume that has no project is asking for a string that
does not exist.

**Snapshot and Total reconcile.** `ProjectDiskRow.snapshot_attributed_bytes`
is the single figure `snapshot_attribution()` exists to produce. The
column rendered `snapshot_above_base_bytes` and fell back to `—` while
the Total was `size - shared` regardless — and in that branch `size -
shared` is the whole 4.7 GB base image, charged per project and then
added again as a base-image row. One field, one rule. The one branch
where the figure *is* the whole image says so rather than passing itself
off as a share.

**The overwrite loop closes.** Traced end to end: a `FILE_EXISTS:`
refusal raises the prompt, Replace re-invokes with `overwrite: true`,
Skip advances, "…all" answers the rest without asking, and picker and
host-drop both reach `uploadFileToContainer` through `uploadPaths`. Two
gaps: a second batch's `askOverwrite` overwrote the first's resolver,
leaving that batch awaiting an answer no dialog could produce; and the
backend's written refusals — a hidden host folder, a path outside the
write roots — were passed as a toast `detail`, which `ToastHost` renders
as collapsed monospace behind a "Details" button, so the only sentence
that explained anything was the part nobody saw. `readableRefusal`
promotes it to the headline when a batch failed the same way.

**The browser pane's sandbox is pinned.** `allow-same-origin` must stay
(the proxy's gate reads `Origin`/`Referer`, and an opaque origin sends
`null`); every top-navigation grant and `allow-popups-to-escape-sandbox`
must stay absent, and the test names the offending token rather than
printing a set diff.

**`@tauri-apps/plugin-store` is gone** from `package.json` — its
capability grants were removed as a host-file-write primitive and nothing
in `app/src` imports it. The lockfile was updated with
`--package-lock-only`, deliberately: `node_modules` is a symlink shared
with other worktrees and a real install would have pulled it out from
under them.

Nothing under `src-tauri/` is touched. 663 frontend tests pass (was 635),
`tsc --noEmit` clean, `npm run build` green, `cargo test` 446 unchanged.

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 12:02:34 -07:00
co-authored by Claude Opus 5
parent 17f031a5d7
commit 7e1f8df1ff
14 changed files with 952 additions and 74 deletions
+81
View File
@@ -114,3 +114,84 @@ export function fileExistsPath(e: unknown): string | null {
* the kind of dialog people dismiss without reading.
*/
export type OverwriteChoice = "replace" | "skip" | "replace-all" | "skip-all";
/**
* Fragments that identify a refusal the backend already wrote **for a person**.
*
* The file commands guard two policies that a user can trip over by accident,
* and both answer with a finished sentence that names the offending path and
* says what to do instead:
*
* ".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.
* Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc
*
* Those sentences were being used as the *detail* of a generic toast
* ("A file could not be uploaded"), and `ToastHost` renders a detail as
* collapsed monospace behind a "Details" button — so the one part of the
* message that explained anything was the part nobody saw. Matching them here
* lets the caller promote the sentence to the toast's headline.
*
* Matched on a stable fragment rather than the whole string, because the path
* and the verb ("save"/"read", "file"/"folder") vary per call. Deliberately a
* short list: an error that is *not* recognised still reports exactly as it
* did before, so a wrong guess here can only fail to promote, never mangle.
*/
const REFUSAL_MARKERS = [
// `validate_host_path` — hidden host component, and system locations.
"Triple-C will not",
// `validate_container_write_path` — outside /workspace, /home/claude, /tmp.
"outside the folders this panel can change",
] as const;
/**
* `Error: …`, `TypeError: …`, `invoke failed: …` — wrappers a JS layer may have
* put in front of the backend's sentence on the way through. Stripped so the
* prose starts where the backend started it; applied twice at most, because a
* doubly-wrapped error is the realistic worst case and looping on user text is
* not.
*/
const WRAPPER_PREFIX = /^(?:uncaught\s*(?:\(in promise\)\s*)?)?(?:[a-z]*error|invoke(?:\s+failed)?)\s*:\s*/i;
function stripWrapper(text: string): string {
let out = text.trim();
for (let i = 0; i < 2; i++) {
const next = out.replace(WRAPPER_PREFIX, "").trim();
if (next === out) break;
out = next;
}
return out;
}
/**
* The backend's own user-facing sentence, when this failure is one — otherwise
* `null`, and the caller reports it however it reported everything else.
*/
export function readableRefusal(e: unknown): string | null {
for (const s of stringsIn(e)) {
const text = stripWrapper(s);
if (REFUSAL_MARKERS.some((marker) => text.includes(marker))) return text;
}
return null;
}
/**
* The most human form of any failure, for the places that show one verbatim.
*
* `String(e)` is what these used to be, which turns a serialised error object
* into `[object Object]` and leaves a JS wrapper prefix on a sentence that
* reads perfectly well without it.
*/
export function errorText(e: unknown): string {
const readable = readableRefusal(e);
if (readable) return readable;
if (typeof e === "string") return stripWrapper(e);
if (e instanceof Error) return stripWrapper(e.message);
const record = asRecord(e);
if (record) {
for (const field of [...MESSAGE_FIELDS, ...KIND_FIELDS]) {
const value = record[field];
if (typeof value === "string" && value.trim().length > 0) return stripWrapper(value);
}
}
return String(e);
}