diff --git a/components/abort-turn-dialog.tsx b/components/abort-turn-dialog.tsx
index 62ba9e1..a97b59b 100644
--- a/components/abort-turn-dialog.tsx
+++ b/components/abort-turn-dialog.tsx
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
+import { useBackdropDismiss } from "@/components/backdrop-dismiss";
/**
* The ESC-abort guard (owner, 2026-10-02): the global Esc → abort chain
@@ -59,6 +60,7 @@ export function AbortTurnBody({
* (owner, 2026-10-04): the line says so. */
busy?: boolean;
}) {
+ const backdropDismiss = useBackdropDismiss(onClose);
const copy = COPY[mode];
return (
{
- if (e.target === e.currentTarget) onClose();
- }}
+ // A genuine click on the scrim cancels — the cautious default, like Esc.
+ {...backdropDismiss}
>
void) {
+ const gesture = useRef<{
+ pointerId: number;
+ backdrop: HTMLDivElement;
+ released: boolean;
+ } | null>(null);
+
+ return {
+ onPointerDownCapture(event: PointerEvent) {
+ gesture.current = event.isPrimary && event.button === 0 && event.target === event.currentTarget
+ ? { pointerId: event.pointerId, backdrop: event.currentTarget, released: false }
+ : null;
+ // Touch can implicitly capture the pointer on press. Let release
+ // target the actual hit element, not a backdrop left behind.
+ if (gesture.current && event.currentTarget.hasPointerCapture?.(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ },
+ onPointerUpCapture(event: PointerEvent) {
+ const press = gesture.current;
+ gesture.current = press && press.pointerId === event.pointerId &&
+ event.isPrimary && event.button === 0 && event.target === event.currentTarget &&
+ press.backdrop === event.currentTarget
+ ? { ...press, released: true }
+ : null;
+ },
+ onPointerCancelCapture() {
+ gesture.current = null;
+ },
+ onClickCapture(event: MouseEvent) {
+ const press = gesture.current;
+ gesture.current = null;
+ if (press?.released && press.backdrop === event.currentTarget &&
+ event.button === 0 && event.target === event.currentTarget) {
+ // This render's callback preserves current busy guards and props.
+ onDismiss();
+ }
+ },
+ };
+}
diff --git a/components/delete-node-dialog.tsx b/components/delete-node-dialog.tsx
index 283f5cc..91f1d2f 100644
--- a/components/delete-node-dialog.tsx
+++ b/components/delete-node-dialog.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
+import { useBackdropDismiss } from "@/components/backdrop-dismiss";
/** The 409 extraction_running line (owner phrasing, node interlude):
* the server's error names the extraction; the ACTIVITY DOCK is the
@@ -146,6 +147,9 @@ export function DeleteNodeDialog({
onDeleted: (nodeIds: string[], graphRevision: number) => void;
}) {
const [busy, setBusy] = useState(false);
+ const backdropDismiss = useBackdropDismiss(() => {
+ if (!busy) onClose();
+ });
const [failure, setFailure] = useState<{ code: string; error: string } | null>(null);
// Fresh state per dialog: closing resets.
@@ -214,9 +218,7 @@ export function DeleteNodeDialog({
return createPortal(
{
- if (!busy) onClose();
- }}
+ {...backdropDismiss}
>
{
+ if (!busy) onClose();
+ });
// Retry IS pressing Delete again: the mode always derives from the
// checkbox's current state (a toggle between failure and retry is a
// change of intent, honored as such — the checkbox is disabled while
@@ -115,9 +119,7 @@ export function DeleteSessionDialog({
return createPortal(
{
- if (!busy) onClose();
- }}
+ {...backdropDismiss}
>
{isEmpty ? (
- // The original home placeholder — kept verbatim for the empty graph.
+ // The empty graph keeps the first action clear without zero-valued statistics.
Knowledge graph
- Every session you complete will map what you know — and where the edge of your
- understanding is — right here.
+ Start a session or import notes to build your graph.
diff --git a/components/home.tsx b/components/home.tsx
index 1e77872..5c674a6 100644
--- a/components/home.tsx
+++ b/components/home.tsx
@@ -260,7 +260,7 @@ export function Home({
onClick={() => setSetupOpen(true)}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
>
- Set up your models — chats start after the three picks
+ Set up your models
)
diff --git a/components/keyboard-shortcuts.tsx b/components/keyboard-shortcuts.tsx
index bc4a3fc..e0bd488 100644
--- a/components/keyboard-shortcuts.tsx
+++ b/components/keyboard-shortcuts.tsx
@@ -24,6 +24,7 @@
*/
import { useEffect, useRef, useState } from "react";
+import { useBackdropDismiss } from "@/components/backdrop-dismiss";
import { useRouter } from "next/navigation";
import {
actionForBinding,
@@ -150,6 +151,7 @@ export function KeyboardShortcuts() {
* Until the settings milestone ships an editor, this is how shortcuts
* stay discoverable. */
function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
+ const backdropDismiss = useBackdropDismiss(onClose);
if (!open) return null;
const scopes = ["global", "chat", "graph"] as const;
const scopeLabels: Record<(typeof scopes)[number], string> = {
@@ -161,7 +163,7 @@ function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
setSetupOpen(true)}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
>
- Set up your models — chats start after the three picks
+ Set up your models
)
diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx
index 1a2b7a3..e3979b4 100644
--- a/components/settings/settings-dialog.tsx
+++ b/components/settings/settings-dialog.tsx
@@ -30,6 +30,7 @@ import {
useState,
} from "react";
import { createPortal } from "react-dom";
+import { useBackdropDismiss } from "@/components/backdrop-dismiss";
import {
CURRENT_SETTINGS_VERSION,
type AgentSettings,
@@ -113,13 +114,14 @@ function SettingsDialog({
/** The keys-section row to unfold (see DialogContext above). */
initialRow: string | null;
}) {
- // Plain dim backdrop (no blur — nothing decorative); the click
- // closes, the dialog body stops propagation.
+ const backdropDismiss = useBackdropDismiss(onClose);
+ // Plain dim backdrop (no blur — nothing decorative); a genuine
+ // backdrop click closes, the dialog body stops propagation.
return createPortal(
,
diff --git a/components/setup-cta.tsx b/components/setup-cta.tsx
index efc174d..e9cea7d 100644
--- a/components/setup-cta.tsx
+++ b/components/setup-cta.tsx
@@ -70,7 +70,7 @@ export function ZeroKeysPill({ onOpen }: { onOpen: () => void }) {
onClick={onOpen}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
>
- Add a provider key — chats start after one unlocks the models
+ Add a provider key
);
diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx
index aeeb615..5b902b7 100644
--- a/components/setup-dialog.tsx
+++ b/components/setup-dialog.tsx
@@ -34,6 +34,7 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
+import { useBackdropDismiss } from "@/components/backdrop-dismiss";
import type React from "react";
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from "@/src/core";
import { prettyModel } from "@/components/usage-format";
@@ -146,9 +147,9 @@ export function SetupKeysBody({
}) {
return (
-
First, add a provider key.
+
Add a provider key.
- The model pickers list only what your keys unlock — there is nothing to pick until one exists.
+ Choose your AI provider and paste its API key. You’ll pick your models next.
- Model catalog could not be loaded — check the connection and retry.
+ Model catalog could not be loaded. Check the connection and retry.
) : (
- Pick the model for each tier — all three are required before chats can start.
+ Pick the model for each tier. All three are required before chats can start.
{SLOT_ORDER.map((slot) => {
const entry = picked[slot];
@@ -513,7 +520,7 @@ export function SetupDialog({
}));
setOpenMenu(null);
}}
- footer={catalog && catalog.length === 0 ? "No image-capable models — widen the provider keys" : undefined}
+ footer={catalog && catalog.length === 0 ? "No image-capable models. Add a provider key with access to a vision model." : undefined}
/>
)}
diff --git a/tests/backdrop-dismiss.test.ts b/tests/backdrop-dismiss.test.ts
new file mode 100644
index 0000000..4e7b155
--- /dev/null
+++ b/tests/backdrop-dismiss.test.ts
@@ -0,0 +1,247 @@
+import assert from "node:assert/strict";
+import { after, afterEach, before, beforeEach, describe, it } from "node:test";
+import { act, createElement, type MouseEvent } from "react";
+import { createPortal } from "react-dom";
+import type { Root } from "react-dom/client";
+import { parseHTML } from "linkedom";
+import { useBackdropDismiss } from "../components/backdrop-dismiss";
+import { AbortTurnBody } from "../components/abort-turn-dialog";
+
+/** Mounted React handlers, including portal propagation and the browser's
+ * common-ancestor click after a cross-boundary press/release. Linkedom does
+ * not synthesize that click for us, so each test dispatches it explicitly. */
+describe("backdrop dismissal requires a whole primary gesture on the scrim", () => {
+ const dom = parseHTML("");
+ const globals = globalThis as Record;
+ const previous = new Map();
+ let root: Root;
+ let host: HTMLDivElement;
+ let createRoot: typeof import("react-dom/client").createRoot;
+ let closes: number;
+ let actions: number;
+
+ before(async () => {
+ for (const [key, value] of Object.entries({
+ window: dom.window,
+ document: dom.document,
+ // ReactDOM reads navigator once it sees a DOM; Node can disable
+ // that global. Keep this fallback local and platform-neutral.
+ navigator: globals.navigator ?? { userAgent: "", platform: "" },
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ })) {
+ previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
+ Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
+ }
+ ({ createRoot } = await import("react-dom/client"));
+ });
+
+ after(() => {
+ for (const [key, descriptor] of previous) {
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else delete globals[key];
+ }
+ });
+
+ function Harness({ onClose = () => { closes++; }, busy = false, open = true }: {
+ onClose?: () => void;
+ busy?: boolean;
+ open?: boolean;
+ }) {
+ const backdropDismiss = useBackdropDismiss(() => {
+ if (!busy) onClose();
+ });
+ if (!open) return null;
+ return createElement("div", { ...backdropDismiss, "data-testid": "backdrop" },
+ createElement("div", {
+ "data-testid": "body",
+ onClick: (event: MouseEvent) => event.stopPropagation(),
+ }, createElement("button", { onClick: () => { actions++; }, "data-testid": "action" }, "Save"),
+ createPortal(createElement("button", {
+ "data-testid": "portal-option",
+ onClick: () => { actions++; },
+ }, "Pick provider"), document.body)),
+ );
+ }
+
+ function element(id: string): HTMLElement {
+ const found = document.querySelector(`[data-testid="${id}"]`);
+ assert.ok(found, id);
+ return found;
+ }
+
+ function dispatch(target: HTMLElement, type: string, fields: Record = {}) {
+ const event = new dom.window.Event(type, { bubbles: true, cancelable: true });
+ Object.assign(event, { pointerId: 1, button: 0, isPrimary: true }, fields);
+ act(() => { target.dispatchEvent(event); });
+ return event;
+ }
+
+ function click(target: HTMLElement, fields: Record = {}) {
+ dispatch(target, "pointerdown", fields);
+ dispatch(target, "pointerup", fields);
+ return dispatch(target, "click", fields);
+ }
+
+ beforeEach(() => {
+ closes = 0;
+ actions = 0;
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+ act(() => { root.render(createElement(Harness)); });
+ });
+
+ afterEach(() => {
+ act(() => { root.unmount(); });
+ host.remove();
+ });
+
+ it("ordinary primary backdrop click closes exactly once; a bare click does not", () => {
+ const backdrop = element("backdrop");
+ dispatch(backdrop, "click");
+ assert.equal(closes, 0);
+ const event = click(backdrop);
+ assert.equal(closes, 1);
+ assert.equal(event.defaultPrevented, false);
+ dispatch(backdrop, "click");
+ assert.equal(closes, 1, "the completed gesture is consumed");
+ });
+
+ it("inside press → backdrop release → common-ancestor click stays open", () => {
+ dispatch(element("body"), "pointerdown");
+ dispatch(element("backdrop"), "pointerup");
+ dispatch(element("backdrop"), "click");
+ assert.equal(closes, 0);
+ click(element("backdrop"));
+ assert.equal(closes, 1, "a later genuine click still works");
+ });
+
+ it("backdrop press → inside release → common-ancestor click stays open", () => {
+ dispatch(element("backdrop"), "pointerdown");
+ dispatch(element("body"), "pointerup");
+ dispatch(element("backdrop"), "click");
+ assert.equal(closes, 0);
+ });
+
+ it("releases implicit touch capture so a drag ending inside is not retargeted to the backdrop", () => {
+ const backdrop = element("backdrop");
+ let released = 0;
+ backdrop.hasPointerCapture = (pointerId) => pointerId === 1;
+ backdrop.releasePointerCapture = (pointerId) => {
+ assert.equal(pointerId, 1);
+ released++;
+ };
+ dispatch(backdrop, "pointerdown", { pointerType: "touch" });
+ assert.equal(released, 1);
+ dispatch(element("body"), "pointerup", { pointerType: "touch" });
+ dispatch(backdrop, "click");
+ assert.equal(closes, 0);
+ });
+
+ it("a release is required and a mismatched pointer cannot finish the press", () => {
+ const backdrop = element("backdrop");
+ dispatch(backdrop, "pointerdown");
+ dispatch(backdrop, "click");
+ assert.equal(closes, 0);
+ dispatch(backdrop, "pointerdown");
+ dispatch(backdrop, "pointerup", { pointerId: 2 });
+ dispatch(backdrop, "click");
+ assert.equal(closes, 0);
+ });
+
+ it("pointer cancellation invalidates the gesture, before or after release", () => {
+ const backdrop = element("backdrop");
+ dispatch(backdrop, "pointerdown");
+ dispatch(backdrop, "pointercancel");
+ dispatch(backdrop, "pointerup");
+ dispatch(backdrop, "click");
+ assert.equal(closes, 0);
+ dispatch(backdrop, "pointerdown");
+ dispatch(backdrop, "pointerup");
+ dispatch(backdrop, "pointercancel");
+ dispatch(backdrop, "click");
+ assert.equal(closes, 0);
+ });
+
+ it("right/middle clicks and non-primary pointers never dismiss", () => {
+ for (const fields of [{ button: 1 }, { button: 2 }, { isPrimary: false }]) {
+ click(element("backdrop"), fields);
+ assert.equal(closes, 0);
+ }
+ });
+
+ it("inside buttons retain their own action without closing the dialog", () => {
+ click(element("action"));
+ assert.equal(actions, 1);
+ assert.equal(closes, 0);
+ });
+
+ it("portaled dropdown picks work and neither cross-boundary direction closes the parent", () => {
+ const option = element("portal-option");
+ const backdrop = element("backdrop");
+ assert.equal(backdrop.contains(option), false, "the menu is physically outside the backdrop");
+ click(option);
+ assert.equal(actions, 1);
+ assert.equal(closes, 0);
+ dispatch(option, "pointerdown");
+ dispatch(backdrop, "pointerup");
+ dispatch(backdrop, "click");
+ assert.equal(closes, 0);
+ dispatch(backdrop, "pointerdown");
+ dispatch(option, "pointerup");
+ dispatch(backdrop, "click");
+ assert.equal(closes, 0);
+ });
+
+ it("uses the latest callback and busy guard even when props change mid-gesture", () => {
+ const backdrop = element("backdrop");
+ dispatch(backdrop, "pointerdown");
+ act(() => { root.render(createElement(Harness, { busy: true })); });
+ dispatch(backdrop, "pointerup");
+ dispatch(backdrop, "click");
+ assert.equal(closes, 0, "new busy protection is honored");
+
+ act(() => { root.render(createElement(Harness)); });
+ dispatch(backdrop, "pointerdown");
+ let currentCalls = 0;
+ act(() => { root.render(createElement(Harness, { onClose: () => { currentCalls++; } })); });
+ dispatch(backdrop, "pointerup");
+ dispatch(backdrop, "click");
+ assert.equal(closes, 0, "the old callback is not retained");
+ assert.equal(currentCalls, 1);
+ });
+
+ it("can render closed/open without changing hook order or reusing a removed backdrop press", () => {
+ dispatch(element("backdrop"), "pointerdown");
+ act(() => { root.render(createElement(Harness, { open: false })); });
+ act(() => { root.render(createElement(Harness)); });
+ dispatch(element("backdrop"), "pointerup");
+ dispatch(element("backdrop"), "click");
+ assert.equal(closes, 0);
+ click(element("backdrop"));
+ assert.equal(closes, 1);
+ });
+
+ it("AbortTurnBody adopts the guard while retaining Cancel and Confirm actions", () => {
+ act(() => {
+ root.render(createElement(AbortTurnBody, {
+ mode: "turn",
+ onClose: () => { closes++; },
+ onConfirm: () => { actions++; },
+ }));
+ });
+ const backdrop = element("abort-turn-dialog");
+ dispatch(element("abort-turn-title"), "pointerdown");
+ dispatch(backdrop, "pointerup");
+ dispatch(backdrop, "click");
+ assert.equal(closes, 0);
+ click(element("abort-turn-confirm"));
+ assert.equal(actions, 1);
+ assert.equal(closes, 0);
+ click(element("abort-turn-cancel"));
+ assert.equal(closes, 1);
+ click(backdrop);
+ assert.equal(closes, 2);
+ });
+});
diff --git a/tests/render-new-session.test.tsx b/tests/render-new-session.test.tsx
index 5dc1b48..e77776d 100644
--- a/tests/render-new-session.test.tsx
+++ b/tests/render-new-session.test.tsx
@@ -83,13 +83,15 @@ describe("DraftShell — the sessionless surface", () => {
onSend={() => {}}
setupCta={
}
/>,
);
// The same affordance home carries, above the draft shell's blob.
assert.ok(html.includes('data-testid="setup-cta"'));
+ assert.match(html, />Set up your models<\/button>/);
+ assert.doesNotMatch(html, /chats start after/);
// Above the composer: the CTA's markup precedes the blob's.
const cta = html.indexOf('data-testid="setup-cta"');
const blob = html.search(/rounded-2xl[^>]*pointer-events-auto|pointer-events-auto[^>]*rounded-2xl/);
diff --git a/tests/setup-dialog-interaction.test.tsx b/tests/setup-dialog-interaction.test.tsx
new file mode 100644
index 0000000..186cec6
--- /dev/null
+++ b/tests/setup-dialog-interaction.test.tsx
@@ -0,0 +1,185 @@
+import React, { act } from "react";
+(globalThis as Record).React = React;
+
+import assert from "node:assert/strict";
+import { afterEach, describe, it } from "node:test";
+import { createRoot, type Root } from "react-dom/client";
+import { parseHTML } from "linkedom";
+import { SetupDialog } from "../components/setup-dialog.tsx";
+import type { ModelCatalogEntry } from "../src/core/contracts/models.ts";
+
+// As in render-update-banner: mount real React handlers against an isolated
+// DOM. Fetch is wholly stubbed; no key, settings, or session writes can escape.
+const dom = parseHTML("");
+Object.assign(globalThis, {
+ window: dom.window,
+ document: dom.document,
+ Element: dom.window.Element,
+ HTMLElement: dom.window.HTMLElement,
+ HTMLInputElement: dom.window.HTMLInputElement,
+ HTMLTextAreaElement: dom.window.HTMLTextAreaElement,
+ CustomEvent: dom.window.CustomEvent,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0),
+ cancelAnimationFrame: (id: number) => clearTimeout(id),
+});
+Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 });
+dom.window.HTMLElement.prototype.scrollIntoView = () => {};
+
+const originalFetch = globalThis.fetch;
+const provider = { id: "openrouter", envVar: "OPENROUTER_API_KEY", label: "OpenRouter" };
+const model: ModelCatalogEntry = {
+ id: "fixture-text",
+ name: "Fixture text",
+ provider: provider.id,
+ reasoning: false,
+ input: ["text"],
+ supportedLevels: ["off"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 8192,
+ maxTokens: 1024,
+};
+let root: Root | null = null;
+let host: HTMLDivElement | null = null;
+let closed = 0;
+let completed = 0;
+
+async function mount({ keyed = false, catalogFails = false } = {}) {
+ closed = 0;
+ completed = 0;
+ globalThis.fetch = async (input, init) => {
+ assert.equal(init?.method ?? "GET", "GET", "interaction tests must not write data");
+ const path = new URL(String(input), "http://localhost").pathname;
+ if (path === "/api/keys") return Response.json({ providers: [provider], keys: [] });
+ assert.equal(path, "/api/models");
+ if (catalogFails) throw new Error("fixture catalog failure");
+ return Response.json({
+ providers: [{ ...provider, keyPresent: keyed }],
+ models: keyed ? [model] : [],
+ slots: { strong: null, cheap: null, vision: null },
+ });
+ };
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ function Harness() {
+ const [open, setOpen] = React.useState(true);
+ return { closed++; setOpen(false); }} onDone={() => { completed++; }} />;
+ }
+ await act(async () => { root!.render(); });
+}
+
+function element(selector: string): HTMLElement {
+ const node = document.querySelector(selector);
+ assert.ok(node, `expected ${selector}`);
+ return node;
+}
+
+async function event(target: HTMLElement, type: string) {
+ await act(async () => {
+ const dispatched = new dom.window.Event(type, { bubbles: true, cancelable: true });
+ Object.assign(dispatched, { pointerId: 1, pointerType: "mouse", isPrimary: true, button: 0, detail: 1 });
+ target.dispatchEvent(dispatched);
+ });
+}
+
+async function click(target: HTMLElement) {
+ for (const type of ["pointerdown", "pointerup", "click"]) await event(target, type);
+}
+
+async function escape() {
+ await act(async () => {
+ const key = new dom.window.Event("keydown", { bubbles: true, cancelable: true });
+ Object.assign(key, { key: "Escape" });
+ document.dispatchEvent(key);
+ });
+}
+
+afterEach(async () => {
+ await act(async () => { root?.unmount(); });
+ root = null;
+ host?.remove();
+ host = null;
+ globalThis.fetch = originalFetch;
+ assert.equal(completed, 0, "opening or dismissing setup must not save models");
+});
+
+describe("setup polish interactions", () => {
+ it("provider dropdown closes on a click elsewhere inside the dialog without closing setup", async () => {
+ await mount();
+ await click(element('[data-testid="setup-key-provider"]'));
+ assert.ok(document.querySelector('[data-testid="setup-key-provider-menu"]'));
+ await click(element('[data-testid="setup-dialog"] h2'));
+ assert.equal(document.querySelector('[data-testid="setup-key-provider-menu"]'), null);
+ assert.ok(document.querySelector('[data-testid="setup-dialog"]'));
+ assert.equal(closed, 0);
+ });
+
+ it("provider trigger still toggles and picking an option still opens the key field", async () => {
+ await mount();
+ await click(element('[data-testid="setup-key-provider"]'));
+ await click(element('[data-testid="setup-key-provider"]'));
+ assert.equal(document.querySelector('[data-testid="setup-key-provider-menu"]'), null);
+ await click(element('[data-testid="setup-key-provider"]'));
+ await click(element('[data-testid="provider-option-openrouter"]'));
+ assert.equal(document.querySelector('[data-testid="setup-key-provider-menu"]'), null);
+ assert.ok(document.querySelector('[data-testid="setup-key-input"]'));
+ assert.equal(closed, 0);
+ });
+
+ it("Escape closes the provider dropdown first, then setup on the next press", async () => {
+ await mount();
+ await click(element('[data-testid="setup-key-provider"]'));
+ await escape();
+ assert.equal(document.querySelector('[data-testid="setup-key-provider-menu"]'), null);
+ assert.ok(document.querySelector('[data-testid="setup-dialog"]'));
+ assert.equal(closed, 0);
+ await escape();
+ assert.equal(closed, 1);
+ assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null);
+ });
+
+ it("a genuine backdrop click closes setup and its open dropdown", async () => {
+ await mount();
+ await click(element('[data-testid="setup-key-provider"]'));
+ await click(element('[data-testid="setup-backdrop"]'));
+ assert.equal(closed, 1);
+ assert.equal(document.querySelector('[data-testid="setup-dialog"]'), null);
+ assert.equal(document.querySelector('[data-testid="setup-key-provider-menu"]'), null);
+ });
+
+ it("inside press followed by backdrop release and ancestor click does not dismiss setup", async () => {
+ await mount();
+ await event(element('[data-testid="setup-dialog"] h2'), "pointerdown");
+ await event(element('[data-testid="setup-backdrop"]'), "pointerup");
+ await event(element('[data-testid="setup-backdrop"]'), "click");
+ assert.equal(closed, 0);
+ assert.ok(document.querySelector('[data-testid="setup-dialog"]'));
+ });
+
+ it("model and thinking dropdowns keep outside-click dismissal and model guidance uses two sentences", async () => {
+ await mount({ keyed: true });
+ assert.equal(element('[data-testid="setup-stage-pickers"] > p').textContent, "Pick the model for each tier. All three are required before chats can start.");
+ await click(element('[data-testid="setup-trigger-strong"]'));
+ await click(element('[data-testid="setup-dialog"] h2'));
+ assert.equal(document.querySelector('[data-testid="setup-menu-strong"]'), null);
+ await click(element('[data-testid="setup-trigger-strong"]'));
+ await click(element('[data-testid="model-option-fixture-text"]'));
+ await click(element('[data-testid="setup-thinking-strong"]'));
+ assert.ok(document.querySelector('[data-testid="setup-thinking-menu-strong"]'));
+ await click(element('[data-testid="setup-dialog"] h2'));
+ assert.equal(document.querySelector('[data-testid="setup-thinking-menu-strong"]'), null);
+ assert.equal(closed, 0);
+ });
+
+ it("the empty vision list gives the approved next action without an em dash", async () => {
+ await mount({ keyed: true });
+ await click(element('[data-testid="setup-trigger-vision"]'));
+ assert.ok(element('[data-testid="setup-menu-vision"]').textContent?.includes("No image-capable models. Add a provider key with access to a vision model."));
+ });
+
+ it("catalog failure uses the approved two-sentence error", async () => {
+ await mount({ catalogFails: true });
+ assert.ok(element('[data-testid="setup-dialog"]').textContent?.includes("Model catalog could not be loaded. Check the connection and retry."));
+ });
+});
diff --git a/tests/zero-keys.test.tsx b/tests/zero-keys.test.tsx
index a8804ab..16f0fe3 100644
--- a/tests/zero-keys.test.tsx
+++ b/tests/zero-keys.test.tsx
@@ -52,7 +52,8 @@ describe("ZeroKeysPill — the composer-hoisted notice", () => {
it("the pill takes the CTA's chrome with its own testid and copy", () => {
const html = renderToStaticMarkup(createElement(ZeroKeysPill, { onOpen: () => {} }));
assert.ok(html.includes('data-testid="setup-cta-keys"'));
- assert.ok(html.includes("Add a provider key"));
+ assert.match(html, />Add a provider key<\/button>/);
+ assert.doesNotMatch(html, /chats start|—/);
// the models CTA's testid must not appear on the keys pill
assert.ok(!html.includes('data-testid="setup-cta"'));
});
@@ -75,8 +76,9 @@ describe("SetupKeysBody — stage 1's self-contained first-key screen", () => {
const html = renderToStaticMarkup(
createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: null }),
);
- assert.match(html, /First, add a provider key\./);
- assert.ok(html.includes("The model pickers list only what your keys unlock"));
+ assert.match(html, /Add a provider key\./);
+ assert.ok(html.includes("Choose your AI provider and paste its API key. You’ll pick your models next."));
+ assert.doesNotMatch(html, /First, add|keys unlock|—/);
assert.ok(html.includes('data-testid="setup-key-provider"'));
assert.ok(html.includes("Pick a provider…"));
assert.ok(!html.includes('data-testid="setup-key-input"'), "no paste field before a provider is picked");