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
@@ -1,7 +1,24 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup } from "@testing-library/react";
import { render, fireEvent, cleanup, act } from "@testing-library/react";
import TerminalView, { supersedes } from "./TerminalView";
import { useAppState } from "../../store/appState";
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
/**
* The window-wide native drag-drop listener, captured at registration.
*
* Tauri routes *every* file drop to *every* listener, which is the whole reason
* `TerminalView` hit-tests one — so a test that wants to know what the hit test
* decides has to be able to fire the event itself.
*/
const dragDrop = vi.hoisted(() => ({
handler: null as null | ((event: unknown) => unknown),
}));
/** The `terminal-output-{id}` listeners, so a test can be the PTY. */
const ptyOutput = vi.hoisted(() => ({
listeners: new Map<string, (e: { payload: number[] }) => void>(),
}));
/**
* Shift+Enter has to reach the container as ESC+CR.
@@ -30,7 +47,10 @@ vi.mock("../../lib/tauri-commands", () => ({
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
listen: async (event: string, cb: (e: { payload: number[] }) => void) => {
ptyOutput.listeners.set(event, cb);
return () => ptyOutput.listeners.delete(event);
},
}));
vi.mock("@tauri-apps/plugin-opener", () => ({
@@ -38,7 +58,14 @@ vi.mock("@tauri-apps/plugin-opener", () => ({
}));
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({ onDragDropEvent: vi.fn(async () => () => {}) }),
getCurrentWebview: () => ({
onDragDropEvent: async (cb: (event: unknown) => unknown) => {
dragDrop.handler = cb;
return () => {
dragDrop.handler = null;
};
},
}),
}));
/** jsdom has no ResizeObserver, and the mount effect installs one. */
@@ -96,6 +123,11 @@ beforeEach(() => {
}),
);
terminalInput.mockClear();
vi.mocked(uploadHostFileToTerminal).mockClear();
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
dragDrop.handler = null;
ptyOutput.listeners.clear();
document.body.innerHTML = "";
useAppState.setState({ sessions: [] });
});
@@ -200,6 +232,14 @@ describe("supersedes — who owns the prompt slot", () => {
expect(supersedes(relay(COMPLETE), guess(TRUNCATED))).toBe(true);
});
it("refuses to let a truncated guess replace another guess it truncates", () => {
// The same rule one rank down. Both are scrapes of the same repainting
// frame, so recency says the newer one wins and recency is wrong: a
// repaint that lands a *shorter* view of the link already on screen is
// showing less of it, not something new.
expect(supersedes(guess(TRUNCATED), guess(COMPLETE))).toBe(false);
});
it("lets a scraped candidate grow into the complete link", () => {
// A repaint can land the truncated copy first. Extending it is safe: a
// longer string with the same prefix has the same origin.
@@ -222,3 +262,162 @@ describe("supersedes — who owns the prompt slot", () => {
).toBe(true);
});
});
describe("TerminalView — where a dropped file lands", () => {
/** Mount, let the async drag-drop registration settle, and give the pane a
* rect — jsdom has no layout, so every element is 0×0 and would be rejected
* as a hidden pane. */
async function mountWithLayout() {
const view = mountSession("bash");
await act(async () => {});
const pane = view.container.querySelector(".xterm")?.parentElement;
if (!pane) throw new Error("terminal host element not found");
pane.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
return view;
}
async function drop(x: number, y: number) {
if (!dragDrop.handler) throw new Error("no drag-drop listener registered");
await act(async () => {
await dragDrop.handler!({
payload: { type: "drop", position: { x, y }, paths: ["/host/dropped.txt"] },
});
});
}
it("uploads a file dropped onto the pane", async () => {
await mountWithLayout();
await drop(400, 300);
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledWith(
"s1",
"/host/dropped.txt",
);
});
it("ignores a drop that lands outside the pane", async () => {
await mountWithLayout();
await drop(4000, 300);
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
});
it("ignores a drop released onto an open modal", async () => {
// The hit test used to be purely geometric, and a `Modal` is a
// `fixed inset-0 z-50` portal painted *over* the whole window — so the pane
// underneath still had its rect and happily uploaded the file into the
// directory the dialog was covering. Same for the shutdown overlay, which is
// up precisely while nothing should be accepting work.
await mountWithLayout();
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
document.body.appendChild(dialog);
await drop(400, 300);
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
// …and it is the modal, not the mount, that is refusing: close it and the
// very same drop goes through.
dialog.remove();
await drop(400, 300);
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
});
});
describe("TerminalView — reaching the URL prompt without a mouse", () => {
// This toast is the only route to completing a sign-in started in a terminal.
// It used to be mouse-only: nothing moved focus to it, nothing dismissed it
// from the keyboard, and xterm's helper textarea eats Tab, so its buttons
// could not be reached at all.
const SIGN_IN =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
/** What `container/triple-c-open` writes to its controlling terminal. */
function relaySequence(url: string): number[] {
const payload = btoa(url);
return Array.from(
new TextEncoder().encode(`\x1b]7777;open;${payload}\x07`),
);
}
/** Mount, and let the container ask for a URL to be opened. */
async function mountWithPrompt() {
const view = mountSession("claude");
await act(async () => {});
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({ payload: relaySequence(SIGN_IN) });
// xterm parses on its own write queue.
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
return view;
}
function primaryAction(): HTMLElement {
const el = document.querySelector<HTMLElement>('[data-url-toast-primary="true"]');
if (!el) throw new Error("toast default action not found");
return el;
}
it("does not take focus away from the terminal when the prompt appears", async () => {
// Deliberate: the terminal is live, and the default action opens a URL the
// *container* chose. A focused button is one stray Enter from doing it.
const { container } = await mountWithPrompt();
expect(document.querySelector('[data-testid="url-toast"]')).not.toBeNull();
expect(document.activeElement).toBe(helperTextarea(container));
});
it("jumps to the default action on Ctrl+Shift+O", async () => {
const { container } = await mountWithPrompt();
fireEvent.keyDown(helperTextarea(container), {
key: "O",
ctrlKey: true,
shiftKey: true,
});
expect(document.activeElement).toBe(primaryAction());
});
it("dismisses on Escape and hands focus back to the terminal", async () => {
// Not back to `document.body`, where the next keystroke goes nowhere.
const { container } = await mountWithPrompt();
fireEvent.keyDown(helperTextarea(container), {
key: "O",
ctrlKey: true,
shiftKey: true,
});
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(document.querySelector('[data-testid="url-toast"]')).toBeNull();
expect(document.activeElement).toBe(helperTextarea(container));
});
it("leaves Ctrl+Shift+O to the terminal when there is no prompt", async () => {
const { container } = mountSession("claude");
await act(async () => {});
const before = document.activeElement;
fireEvent.keyDown(helperTextarea(container), {
key: "O",
ctrlKey: true,
shiftKey: true,
});
expect(document.activeElement).toBe(before);
});
});
+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 && (
+107 -2
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import UrlToast from "./UrlToast";
import { fireEvent, render, screen } from "@testing-library/react";
import UrlToast, { URL_TOAST_PRIMARY_SELECTOR } from "./UrlToast";
/**
* The toast is the *only* thing standing between a container-chosen URL and
@@ -59,6 +59,111 @@ describe("UrlToast", () => {
expect(onOpen).toHaveBeenCalledTimes(1);
});
describe("keyboard", () => {
// This toast is the only route to completing a sign-in started in a
// terminal, and xterm's helper textarea swallows Tab — so without these it
// is unreachable for a keyboard-only user.
const SIGN_IN =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
it("does not take focus from the live terminal when it appears", () => {
// Deliberate. The user may be mid-command, and the default action opens a
// URL the *container* chose — a focused button is one stray Enter away
// from doing it. The shortcut hint below is what makes that affordable.
render(
<UrlToast url="https://example.com/" onOpen={noop} onDismiss={noop} />,
);
expect(document.activeElement).toBe(document.body);
});
it("says how to reach it, since nothing announces a shortcut by itself", () => {
render(
<UrlToast url="https://example.com/" onOpen={noop} onDismiss={noop} />,
);
expect(screen.getByTestId("url-toast-shortcut")).toHaveTextContent(
"Ctrl+Shift+O",
);
});
it("marks the default action so the shortcut has somewhere to land", () => {
// Which button that is depends on the URL, so the marker moves with the
// decision rather than the owner having to repeat it.
const { rerender } = render(
<UrlToast
url="https://github.com/login/device?code=ABCD"
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
expect(
document.querySelector(URL_TOAST_PRIMARY_SELECTOR),
).toHaveTextContent("Open");
rerender(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
expect(
document.querySelector(URL_TOAST_PRIMARY_SELECTOR),
).toHaveTextContent("In container");
});
it("dismisses on Escape from anywhere inside it", () => {
const onDismiss = vi.fn();
render(
<UrlToast
url="https://example.com/"
onOpen={noop}
onDismiss={onDismiss}
/>,
);
fireEvent.keyDown(screen.getByRole("button", { name: "Open" }), {
key: "Escape",
});
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("does not answer Escape pressed outside it", () => {
// Escape belongs to whatever is running in the terminal — vim, above all.
// A document-level binding would break it for everyone who never looked
// at this toast.
const onDismiss = vi.fn();
render(
<UrlToast
url="https://example.com/"
onOpen={noop}
onDismiss={onDismiss}
/>,
);
fireEvent.keyDown(document.body, { key: "Escape" });
expect(onDismiss).not.toHaveBeenCalled();
});
it("gives every action a real button, so Tab reaches all three", () => {
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
const names = screen
.getAllByRole("button")
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
expect(names).toEqual(["In container", "Open", "Dismiss"]);
// Nothing is taken out of the tab order.
for (const b of screen.getAllByRole("button")) {
expect(b).not.toHaveAttribute("tabindex", "-1");
}
});
});
describe("Anthropic sign-in links", () => {
// The callback listener a `claude login` is waiting on is *inside* the
// container. Sending the user to their host browser completes the sign-in
+85 -71
View File
@@ -1,5 +1,32 @@
import type { CSSProperties, MouseEvent } from "react";
import type { KeyboardEvent } from "react";
import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay";
import Button from "../ui/Button";
/**
* Marks the toast's subtree. `TerminalView` uses it to answer "is focus inside
* the thing I am about to unmount?", which is what decides whether dismissing
* has to hand focus back to the terminal.
*/
export const URL_TOAST_SELECTOR = '[data-testid="url-toast"]';
/**
* The chord that jumps from the terminal into this toast.
*
* Bound in `TerminalView` on `document` in the capture phase, the same way
* `useKeyboardShortcuts` binds the app's other chords, because xterm would
* otherwise forward it to the shell. Shift is what keeps it clear of the
* terminal: plain Ctrl+O is readline's `operate-and-get-next`.
*/
export const URL_TOAST_SHORTCUT = "Ctrl+Shift+O";
/**
* Marks the *default* action inside the toast, so the owner can put focus
* there without a ref threaded through `ui/Button` — which is a plain function
* component and not this file's to change. Which button it is depends on the
* URL (see the sign-in note below), so the attribute moves with the decision
* rather than the caller having to repeat it.
*/
export const URL_TOAST_PRIMARY_SELECTOR = '[data-url-toast-primary="true"]';
interface Props {
/** Already validated by `sanitizeRelayUrl` — this component never opens it. */
@@ -41,6 +68,28 @@ interface Props {
* with no host round trip and no auth bridge, so it leads — and the host button
* stays, because a user who has the auth bridge on, or who wants their existing
* browser session, still needs it.
*
* ## Reachable without a mouse, and it does not take focus to manage it
*
* This toast is the only route to completing a sign-in started in a terminal,
* and it used to be mouse-only: xterm's helper textarea swallows Tab, so there
* was no way to reach these buttons at all from the keyboard.
*
* The obvious fix — focus the default action when the toast appears — was
* rejected on two counts. The terminal underneath is *live*: the user may be
* mid-command, and every keystroke after the steal would go to a button instead
* of the shell. Worse, the default action opens a URL chosen by the untrusted
* side of the sandbox, and a focused button is one stray Space or Enter away
* from doing it. This prompt exists precisely to make that a deliberate act.
*
* So focus stays where the user put it and the toast is reachable on demand:
* {@link URL_TOAST_SHORTCUT} jumps to the default action (the hint is on
* screen, next to the label, because a shortcut nobody is told about is not a
* route), Tab then moves between the actions normally — this subtree is not
* inside xterm — and Escape dismisses. Escape is handled *here*, on the
* toast's own subtree, rather than globally: Escape belongs to whatever is
* running in the terminal, and a document-level binding for it would break vim
* for everyone who never looked at this toast.
*/
export default function UrlToast({
url,
@@ -55,82 +104,56 @@ export default function UrlToast({
// host button is the only action there is, so it stays primary.
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
// Filled uses `--accent-emphasis`, never `--accent`the latter is the
// `Button` already owns the filled/outlined variantsincluding the rule
// that filled uses `--accent-emphasis` and never `--accent`, which is the
// foreground/link accent and fails WCAG AA behind white text.
const primaryStyle: CSSProperties = {
padding: "4px 12px",
fontSize: 12,
fontWeight: 600,
color: "#fff",
background: "var(--accent-emphasis)",
border: "1px solid transparent",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
};
const secondaryStyle: CSSProperties = {
padding: "4px 10px",
fontSize: 12,
fontWeight: 600,
color: "var(--text-primary)",
background: "transparent",
border: "1px solid var(--border-color)",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
};
/** Hover feedback for whichever button is currently the filled one. */
const hover = (primary: boolean) =>
primary
? {
onMouseEnter: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "var(--accent-emphasis-hover)"),
onMouseLeave: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "var(--accent-emphasis)"),
}
: {
onMouseEnter: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "var(--bg-tertiary)"),
onMouseLeave: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "transparent"),
};
const hostButton = (
<button
<Button
variant={signIn ? "secondary" : "primary"}
data-url-toast-primary={signIn ? undefined : "true"}
onClick={onOpen}
className="flex-shrink-0"
title={
signIn
? "Open in your own browser instead — the callback then has to reach the container by some other route"
: undefined
}
style={signIn ? secondaryStyle : primaryStyle}
{...hover(!signIn)}
>
Open
</button>
</Button>
);
const containerButton = onOpenInContainer && (
// A sign-in completed in the *container's* browser lands its callback on
// the container's own loopback, which is where the tool waiting for it is
// listening — no host round trip, no auth bridge.
<button
<Button
variant={signIn ? "primary" : "secondary"}
data-url-toast-primary={signIn ? "true" : undefined}
onClick={onOpenInContainer}
className="flex-shrink-0"
title="Open in a browser inside the container, and watch it in the Browser tab"
style={signIn ? primaryStyle : secondaryStyle}
{...hover(signIn)}
>
In container
</button>
</Button>
);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== "Escape") return;
// Scoped to this subtree, so the terminal's own Escape is untouched.
e.preventDefault();
e.stopPropagation();
onDismiss();
};
return (
<div
className="animate-slide-down"
data-testid="url-toast"
role="status"
aria-atomic="true"
aria-keyshortcuts="Control+Shift+O"
onKeyDown={onKeyDown}
style={{
position: "absolute",
top: 12,
@@ -144,7 +167,7 @@ export default function UrlToast({
background: "var(--bg-secondary)",
border: "1px solid var(--border-color)",
borderRadius: 8,
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
boxShadow: "var(--shadow-overlay)",
maxWidth: "min(90%, 600px)",
}}
>
@@ -157,6 +180,11 @@ export default function UrlToast({
}}
>
{label}
{" · "}
<span data-testid="url-toast-shortcut" style={{ fontFamily: "monospace" }}>
{URL_TOAST_SHORTCUT}
</span>{" "}
to reach the buttons, Esc to dismiss
</div>
<div
data-testid="url-toast-url"
@@ -226,29 +254,15 @@ export default function UrlToast({
</>
)}
<button
<Button
variant="ghost"
onClick={onDismiss}
style={{
padding: "2px 6px",
fontSize: 14,
lineHeight: 1,
color: "var(--text-secondary)",
background: "transparent",
border: "none",
borderRadius: 4,
cursor: "pointer",
flexShrink: 0,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.color = "var(--text-primary)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = "var(--text-secondary)")
}
className="flex-shrink-0"
aria-label="Dismiss"
title="Dismiss (Esc)"
>
</button>
</Button>
</div>
);
}