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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 variants — including 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user