From 487a63eaa53a765de63e55a0090887ff4b7bb0f1 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 17 Oct 2026 00:38:57 +0300 Subject: [PATCH] ui: polish first-run setup copy and dialog dismissal --- components/abort-turn-dialog.tsx | 8 +- components/backdrop-dismiss.ts | 48 +++++ components/delete-node-dialog.tsx | 8 +- components/delete-session-dialog.tsx | 8 +- components/graph-view.tsx | 5 +- components/home.tsx | 2 +- components/keyboard-shortcuts.tsx | 4 +- components/new-session.tsx | 2 +- components/settings/settings-dialog.tsx | 8 +- components/setup-cta.tsx | 2 +- components/setup-dialog.tsx | 25 ++- tests/backdrop-dismiss.test.ts | 247 ++++++++++++++++++++++++ tests/render-new-session.test.tsx | 4 +- tests/setup-dialog-interaction.test.tsx | 185 ++++++++++++++++++ tests/zero-keys.test.tsx | 8 +- 15 files changed, 531 insertions(+), 33 deletions(-) create mode 100644 components/backdrop-dismiss.ts create mode 100644 tests/backdrop-dismiss.test.ts create mode 100644 tests/setup-dialog-interaction.test.tsx 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.

@@ -225,6 +226,7 @@ export function SetupDialog({ onClose: () => void; onDone: (models: ModelsSettings) => void; }) { + const backdropDismiss = useBackdropDismiss(onClose); const [body, setBody] = useState(null); const [failed, setFailed] = useState(false); const [picked, setPicked] = useState>>({}); @@ -315,8 +317,13 @@ export function SetupDialog({ else onClose(); }; const onPointerDown = (e: PointerEvent) => { + if (!(e.target instanceof Element)) return; + const t = e.target; + if (keyMenuOpen) { + if (t.closest("[data-lh-menu]") || t.closest('[data-testid="setup-key-provider"]')) return; + setKeyMenuOpen(false); + } if (openMenu === null) return; - const t = e.target as Element; const triggerTestid = openMenu.kind === "model" ? `setup-trigger-${openMenu.slot}` : `setup-thinking-${openMenu.slot}`; if (t.closest("[data-lh-menu]") || t.closest(`[data-testid="${triggerTestid}"]`)) return; @@ -351,7 +358,7 @@ export function SetupDialog({ setKeySaving(false); if (!res || !res.ok) { const body = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; - setKeyError(res ? (body?.error ?? `Save failed (${res.status})`) : "Save failed — network"); + setKeyError(res ? (body?.error ?? `Save failed (${res.status})`) : "Save failed. Check your connection and try again."); return; } setKeyProvider(null); @@ -378,7 +385,7 @@ export function SetupDialog({ }).catch(() => null); if (!res || !res.ok) { setSaving(false); - setError(res ? `Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}` : "Save failed — network"); + setError(res ? `Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}` : "Save failed. Check your connection and try again."); return; } notifySettingsSaved("models"); @@ -389,7 +396,7 @@ export function SetupDialog({
{failed ? (

- 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");