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
+89 -24
View File
@@ -21,7 +21,12 @@ import {
parseUrlRelayOsc,
sanitizeRelayUrl,
} from "../../lib/urlRelay";
import UrlToast from "./UrlToast";
import { isDropTarget } from "../../lib/dropTarget";
import UrlToast, {
URL_TOAST_PRIMARY_SELECTOR,
URL_TOAST_SELECTOR,
URL_TOAST_SHORTCUT,
} from "./UrlToast";
import { trimSelection } from "./trimSelection";
import TerminalContextMenu from "./TerminalContextMenu";
@@ -131,6 +136,26 @@ export default function TerminalView({ sessionId, active }: Props) {
} | null>(null);
const promptSeqRef = useRef(0);
const relayLimiterRef = useRef(new RelayRateLimiter());
// Read by the long-lived keyboard listener below, which is registered once
// and would otherwise close over the prompt as it was at mount.
const urlPromptRef = useRef<{ url: string } | null>(null);
/**
* Empty the prompt slot, and put focus somewhere real if it was inside the
* toast.
*
* The toast never *takes* focus — see the note in `UrlToast` — but a keyboard
* user who jumped into it with {@link URL_TOAST_SHORTCUT} is standing on a
* node that is about to unmount, and React does not rehome focus: it lands on
* `document.body`, where the terminal receives nothing and the next keystroke
* goes nowhere. Every route out of the toast goes through here for that
* reason — Open, In container, ✕, Escape and the auto-dismiss alike.
*/
const dismissUrlPrompt = useCallback(() => {
const wasInside = !!document.activeElement?.closest(URL_TOAST_SELECTOR);
setUrlPrompt(null);
if (wasInside) termRef.current?.focus();
}, []);
/**
* The only writer of the prompt slot. Re-validates whatever the caller
@@ -158,6 +183,38 @@ export default function TerminalView({ sessionId, active }: Props) {
},
[],
);
useEffect(() => {
urlPromptRef.current = urlPrompt;
}, [urlPrompt]);
/**
* The keyboard route into the toast.
*
* Registered on `document` in the capture phase for the same reason
* `useKeyboardShortcuts` does it there: xterm would otherwise forward the
* chord to the shell. It is *not* added to that hook because the target is
* this pane's own toast — the hook has no way to name it, and only one pane
* is on screen at a time, which is what `activeRef` checks.
*
* Nothing is swallowed unless there is a prompt to jump to, so Ctrl+Shift+O
* reaches the terminal untouched the rest of the time.
*/
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
if (e.key !== "o" && e.key !== "O") return;
if (!activeRef.current || !urlPromptRef.current) return;
const primary = terminalContainerRef.current?.querySelector<HTMLElement>(
`${URL_TOAST_SELECTOR} ${URL_TOAST_PRIMARY_SELECTOR}`,
);
if (!primary) return;
e.preventDefault();
e.stopPropagation();
primary.focus();
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, []);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true);
@@ -177,24 +234,21 @@ export default function TerminalView({ sessionId, active }: Props) {
// in-container paths typed into the prompt so Claude Code can read them.
// Tauri intercepts OS file drops at the webview level, so we use
// onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths).
// The listener is window-wide, so we route purely by a hit-test against this
// terminal's bounds: the pane the drop lands on handles it. Inactive panes are
// `display:none` (zero-size rect) so they never match — this works for the
// current tabbed layout and would also do the right thing with split panes.
//
// The listener is window-wide, so every pane decides for itself whether a
// drop was meant for it. `isDropTarget` is that decision, shared with the
// Files pane: the physical-pixel position ÷ `devicePixelRatio` against this
// pane's rect (a hidden pane is `display:none`, so its zero-size rect is what
// stops two panes both claiming the drop), plus z-order — which a rect alone
// cannot see. An open `Modal` is a `fixed inset-0` portal painted *over* the
// window and the pane underneath still has its rect, so the geometric test
// that used to live here uploaded files into the directory a dialog was
// covering. Same for the shutdown overlay, which is on screen precisely while
// nothing should be accepting work at all.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
const insideThisTerminal = (pos: { x: number; y: number }): boolean => {
const rect = containerRef.current?.getBoundingClientRect();
// A hidden (display:none) pane has a zero-size rect — never a drop target.
if (!rect || rect.width === 0 || rect.height === 0) return false;
const dpr = window.devicePixelRatio || 1;
const x = pos.x / dpr;
const y = pos.y / dpr;
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
};
// Always single-quote: a dropped filename can contain shell metacharacters
// ($(), &&, ', spaces) even with no whitespace, and this path is typed into
// a live shell. Single-quoting with '\'' escaping neutralizes all of them.
@@ -203,7 +257,7 @@ export default function TerminalView({ sessionId, active }: Props) {
(async () => {
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== "drop") return;
if (!insideThisTerminal(event.payload.position)) return;
if (!isDropTarget(containerRef.current, event.payload.position)) return;
const paths = event.payload.paths ?? [];
if (paths.length === 0) return;
@@ -391,6 +445,10 @@ export default function TerminalView({ sessionId, active }: Props) {
console.warn("URL relay: rate-limited", url);
return true;
}
// Exact by construction (base64 over OSC 7777), and the detector never
// sees it — so tell it, or a truncated scrape of the same link could
// still fill the slot once this prompt is dismissed.
detectorRef.current?.noteExactUrl(url);
promptUrl(url, "Container asked to open a URL", "relay");
return true;
});
@@ -619,12 +677,19 @@ export default function TerminalView({ sessionId, active }: Props) {
}
}, [active]);
// Auto-dismiss toast after 30 seconds
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
// A keyboard user who has just jumped into the toast is mid-decision, and
// pulling it out from under them costs them the only route to finishing a
// sign-in. It goes when they act on it, which is the same thing a mouse user
// does by clicking.
useEffect(() => {
if (!urlPrompt) return;
const timer = setTimeout(() => setUrlPrompt(null), 30_000);
const timer = setTimeout(() => {
if (document.activeElement?.closest(URL_TOAST_SELECTOR)) return;
dismissUrlPrompt();
}, 30_000);
return () => clearTimeout(timer);
}, [urlPrompt]);
}, [urlPrompt, dismissUrlPrompt]);
// Auto-dismiss image paste message after 3 seconds
useEffect(() => {
@@ -639,13 +704,13 @@ export default function TerminalView({ sessionId, active }: Props) {
// sanitizes, so this can only fail if that invariant is broken — which is
// precisely when it matters that the last thing before `openUrl` checks.
const safe = sanitizeRelayUrl(urlPrompt.url);
setUrlPrompt(null);
dismissUrlPrompt();
if (!safe) {
console.warn("Refusing to open a URL that failed validation");
return;
}
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
}, [urlPrompt]);
}, [urlPrompt, dismissUrlPrompt]);
/**
* Open the prompted URL in the container's own browser instead of the host's.
@@ -658,7 +723,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const handleOpenUrlInContainer = useCallback(() => {
if (!urlPrompt) return;
const safe = sanitizeRelayUrl(urlPrompt.url);
setUrlPrompt(null);
dismissUrlPrompt();
if (!safe) {
console.warn("Refusing to open a URL that failed validation");
return;
@@ -690,7 +755,7 @@ export default function TerminalView({ sessionId, active }: Props) {
detail: String(e),
}),
);
}, [urlPrompt, projectId]);
}, [urlPrompt, projectId, dismissUrlPrompt]);
const handleScrollToBottom = useCallback(() => {
const term = termRef.current;
@@ -770,7 +835,7 @@ export default function TerminalView({ sessionId, active }: Props) {
label={urlPrompt.label}
onOpen={handleOpenUrl}
onOpenInContainer={handleOpenUrlInContainer}
onDismiss={() => setUrlPrompt(null)}
onDismiss={dismissUrlPrompt}
/>
)}
{imagePasteMsg && (