Production tooltips: styled tooltips everywhere, shortcuts surfaced, dev-speak culled

Owner-approved full pass (tooltip wave, 2026-09-25). Mechanism: icon/pill
controls ride the styled Radix tooltip via a new Hint composition
(components/ui/hint.tsx) — the shared primitive adopted as-is, no
restyling; the shortcut appends as an em-dash suffix off the LIVE registry
(a rebind in Settings → Keyboard changes the suffix): "Dictate — ⌘D".
Surface-owned keys (Esc closes, Enter submits) ride a raw binding prop.
Native title= stays only for truncation-reveals (dock spans, the rename
field) and menu-interior abbreviations (MenuShell untouched). One
em-dash pattern; per-state truth on the send square (busy = "Stop the
reply — Esc", idle = "Send — ↵"), Enter on the plan verdict's accept,
"Stop dictation (Esc)" kept verbatim per the owner's flag.

Copy: the dev-speak cull — no pipeline/prober/tier/wave vocabulary
anywhere a user reads. New pins in tests/tooltip-mechanism.test.tsx
(hintText composition, asChild no-wrapper-DOM, the auto-update hint
constant); dock truncation titles pinned native; graph-chrome + settings
pins updated to the new copy.

Live-verified on :3001 — 12.6ms hover appearance (no native 1s lag);
glyphs render on every suffixed surface (⌘D, ⌘K, ⌘⇧F, ⌘,, ⌘B, F2, ↵,
Esc); per-page probes confirm Settings entry, Home logo, session search,
Start session, Close sidebar, model + thinking pills, stats summary,
rename title, plan chip, coverage ring, send square, cost button, node
search (⌘F), graph gear, settings close (Close — Esc), keyboard section
"Fixed — part of this screen", auto-update hint, light-mode "Coming
soon", the three slot rosters, graph-tools hint. Esc still closes the
settings dialog.
This commit is contained in:
amosblomqvist
2026-09-25 01:52:20 +03:00
parent 81be55f35c
commit b19e7e7eae
23 changed files with 393 additions and 127 deletions
+17 -7
View File
@@ -5,6 +5,7 @@ import { useChat } from "@ai-sdk/react";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { createChatTransport } from "@/components/chat-transport"; import { createChatTransport } from "@/components/chat-transport";
import { SetupDialog } from "@/components/setup-dialog"; import { SetupDialog } from "@/components/setup-dialog";
import { Hint } from "@/components/ui/hint";
import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
import { useStickToBottomContext } from "use-stick-to-bottom"; import { useStickToBottomContext } from "use-stick-to-bottom";
import { import {
@@ -912,6 +913,7 @@ export function Chat({
onBlur={() => void commitRename(titleDraft)} onBlur={() => void commitRename(titleDraft)}
spellCheck={false} spellCheck={false}
aria-label="Session title" aria-label="Session title"
title="Rename this session"
className="lh-no-drag min-w-[8ch] max-w-[70%] shrink-0 bg-transparent font-title text-3xl tracking-tight caret-current outline-none [field-sizing:content]" className="lh-no-drag min-w-[8ch] max-w-[70%] shrink-0 bg-transparent font-title text-3xl tracking-tight caret-current outline-none [field-sizing:content]"
/> />
{/* flex-1 spacer during rename too, so the chips stay at {/* flex-1 spacer during rename too, so the chips stay at
@@ -921,10 +923,15 @@ export function Chat({
</> </>
) : ( ) : (
<> <>
<button <Hint
// F2 renames (registry) — surfaced on the hover.
label="Rename session"
action="rename-session"
side="bottom"
>
<button
type="button" type="button"
title="Rename session" onClick={() => {
onClick={() => {
setTitleDraft(titleText); setTitleDraft(titleText);
setRenaming(true); setRenaming(true);
}} }}
@@ -932,6 +939,7 @@ export function Chat({
> >
{titleText} {titleText}
</button> </button>
</Hint>
{/* flex-1 spacer: keeps the chips at the right edge AND stays a {/* flex-1 spacer: keeps the chips at the right edge AND stays a
drag surface (no carve-out) — the freed bar is grippable. */} drag surface (no carve-out) — the freed bar is grippable. */}
<div className="min-w-4 flex-1" aria-hidden="true" /> <div className="min-w-4 flex-1" aria-hidden="true" />
@@ -941,16 +949,17 @@ export function Chat({
structure beside the phase chip — access to the full-DAG popup structure beside the phase chip — access to the full-DAG popup
at every width; the marginalia is the wide-screen glance view. */} at every width; the marginalia is the wide-screen glance view. */}
{planTree && ( {planTree && (
<button <Hint label="Open the full plan" side="bottom">
<button
type="button" type="button"
data-testid="plan-chip" data-testid="plan-chip"
onClick={() => setPlanOpen(true)} onClick={() => setPlanOpen(true)}
title="Open the full plan"
className="lh-no-drag flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground" className="lh-no-drag flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground"
> >
<WaypointsIcon className="size-3.5 text-primary" aria-hidden /> <WaypointsIcon className="size-3.5 text-primary" aria-hidden />
Plan Plan
</button> </button>
</Hint>
)} )}
{/* The extraction ring (owner-locked redesign, 2026-09-24): fills {/* The extraction ring (owner-locked redesign, 2026-09-24): fills
with uncovered vs the chunk threshold, pulses while a run is in with uncovered vs the chunk threshold, pulses while a run is in
@@ -1551,11 +1560,11 @@ function DockOverlay({
mid-question unmount), and widening past the floor fades it mid-question unmount), and widening past the floor fades it
back in. */} back in. */}
{pending && plan && ( {pending && plan && (
<button <Hint label="Open the full plan" side="bottom">
<button
type="button" type="button"
data-testid="plan-marginalia" data-testid="plan-marginalia"
onClick={onOpenPlan} onClick={onOpenPlan}
title="Open the full plan"
style={{ left: `calc(50% + ${DOCK_W / 2 + MARGIN_GAP}px)` }} style={{ left: `calc(50% + ${DOCK_W / 2 + MARGIN_GAP}px)` }}
className={`absolute bottom-5 right-4 top-0 cursor-pointer transition-[filter,opacity] duration-300 ease-out ${ className={`absolute bottom-5 right-4 top-0 cursor-pointer transition-[filter,opacity] duration-300 ease-out ${
expanded && flankW >= MARGIN_MIN_W expanded && flankW >= MARGIN_MIN_W
@@ -1565,6 +1574,7 @@ function DockOverlay({
> >
{plan} {plan}
</button> </button>
</Hint>
)} )}
</div> </div>
</div> </div>
+25 -7
View File
@@ -2,6 +2,7 @@
import { CheckIcon, CornerDownLeftIcon, SquareIcon, XIcon } from "lucide-react"; import { CheckIcon, CornerDownLeftIcon, SquareIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Hint } from "@/components/ui/hint";
/** /**
* The chat composer — content row ONLY (the persistent rounded shell lives in * The chat composer — content row ONLY (the persistent rounded shell lives in
@@ -108,13 +109,21 @@ export function Composer({
// pending on the next message; a side question now rides the // pending on the next message; a side question now rides the
// acceptance as its note instead). // acceptance as its note instead).
<div className="flex shrink-0 flex-col justify-end gap-1" data-testid="plan-verdict-buttons"> <div className="flex shrink-0 flex-col justify-end gap-1" data-testid="plan-verdict-buttons">
<button <Hint
type="button" // Enter accepts too — one press, either button (the user
onClick={verdict.onAccept} // decided 2026-09-19; surface keys honestly per state).
label="Accept the plan"
binding="enter"
side="left"
>
<button
type="button"
onClick={verdict.onAccept}
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-emerald-600 px-3 text-xs font-medium text-white transition-colors hover:bg-emerald-500" className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-emerald-600 px-3 text-xs font-medium text-white transition-colors hover:bg-emerald-500"
> >
<CheckIcon className="size-3" /> Accept plan <CheckIcon className="size-3" /> Accept plan
</button> </button>
</Hint>
<button <button
type="button" type="button"
onClick={verdict.onDecline} onClick={verdict.onDecline}
@@ -124,10 +133,18 @@ export function Composer({
</button> </button>
</div> </div>
) : ( ) : (
<button <Hint
type="submit" // Per-state truth (tooltip wave): busy → the square STOPS the
aria-label={busy ? "Stop" : "Send"} // stream, and Esc aborts it too; otherwise Enter sends when the
disabled={!busy && !canSend} // box has text.
label={busy ? "Stop the reply" : "Send"}
binding={busy ? "escape" : "enter"}
side="left"
>
<button
type="submit"
aria-label={busy ? "Stop" : "Send"}
disabled={!busy && !canSend}
className={cn( className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors", "flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
busy busy
@@ -137,6 +154,7 @@ export function Composer({
> >
{busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />} {busy ? <SquareIcon className="size-4" /> : <CornerDownLeftIcon className="size-5" />}
</button> </button>
</Hint>
)} )}
</form> </form>
); );
+40 -24
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2Icon, MicIcon } from "lucide-react"; import { Loader2Icon, MicIcon } from "lucide-react";
import { useSettingsDialog } from "@/components/settings/settings-dialog"; import { useSettingsDialog } from "@/components/settings/settings-dialog";
import { Hint } from "@/components/ui/hint";
/** /**
* Voice dictation — the cloud engine (STT wave 1, 2026-09-17). * Voice dictation — the cloud engine (STT wave 1, 2026-09-17).
@@ -481,37 +482,51 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
if (!active) { if (!active) {
return ( return (
<button <Hint
type="button" // Errored: the button span truncates the line — the tooltip is its
onClick={() => { // full reveal (the keyless line KEEPS "Deepgram" — owner overrule:
if (keyless) { // it's the Keys row label, the truth the user needs). Otherwise the
clearError(); // quiet label + the live ⌘D binding.
openSettings("keys", DEEPGRAM_KEY_ID); label={error ?? "Dictate"}
} else { action={error ? undefined : "toggle-dictation"}
toggle(); side="top"
} >
}} <button
title={error ?? "Dictate into the composer"} type="button"
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"} onClick={() => {
data-testid="dictation-toggle" if (keyless) {
clearError();
openSettings("keys", DEEPGRAM_KEY_ID);
} else {
toggle();
}
}}
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
data-testid="dictation-toggle"
// Same blurring as the floating chrome (owner, fix wave round 3), // Same blurring as the floating chrome (owner, fix wave round 3),
// but lower opacity — the mic stays quieter than the model pills. // but lower opacity — the mic stays quieter than the model pills.
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground" className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
> >
<MicIcon className="size-3.5" /> <MicIcon className="size-3.5" />
<span className="max-w-full truncate">{error ?? "Dictate"}</span> <span className="max-w-full truncate">{error ?? "Dictate"}</span>
</button> </button>
</Hint>
); );
} }
return ( return (
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar"> <div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
<button <Hint
type="button" // The owner flagged this copy good ("Stop dictation (Esc)") —
onClick={toggle} // kept verbatim; only the machinery moved to the styled tooltip.
disabled={phase === "stopping"} label="Stop dictation (Esc)"
title="Stop dictation (Esc)" side="top"
aria-label="Stop dictation" >
<button
type="button"
onClick={toggle}
disabled={phase === "stopping"}
aria-label="Stop dictation"
data-testid="dictation-toggle" data-testid="dictation-toggle"
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md bg-destructive/10 px-2 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20" className="flex h-7 shrink-0 items-center gap-1.5 rounded-md bg-destructive/10 px-2 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
> >
@@ -520,8 +535,9 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
) : ( ) : (
<MicIcon className="size-3.5" /> <MicIcon className="size-3.5" />
)} )}
Stop Stop
</button> </button>
</Hint>
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden /> <span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
{/* Fixed width while recording — nothing ever appends text beside it {/* Fixed width while recording — nothing ever appends text beside it
(no interims, owner round 1). Cells carry no minimum width; the (no interims, owner round 1). Cells carry no minimum width; the
+18 -11
View File
@@ -5,6 +5,7 @@ import { useActivity } from "@/components/activity-dock";
import { usePopoverDismiss } from "@/components/popover-dismiss"; import { usePopoverDismiss } from "@/components/popover-dismiss";
import { useSettingsSaved } from "@/components/settings-signal"; import { useSettingsSaved } from "@/components/settings-signal";
import { formatTokens } from "@/components/usage-format"; import { formatTokens } from "@/components/usage-format";
import { Hint } from "@/components/ui/hint";
import { timeAgo } from "@/components/sidebar"; import { timeAgo } from "@/components/sidebar";
/** /**
@@ -290,18 +291,24 @@ export function CoverageRing({ chatId, refreshKey = 0 }: { chatId: string; refre
const fill = ringFill(coverage.uncoveredTokens, graph.chunkTokens); const fill = ringFill(coverage.uncoveredTokens, graph.chunkTokens);
return ( return (
<div ref={rootRef} className="lh-no-drag relative shrink-0"> <div ref={rootRef} className="lh-no-drag relative shrink-0">
<button <Hint
type="button" // Plain language (tooltip wave) — "uncovered tokens vs the chunk
data-testid="coverage-ring" // threshold" was internal vocabulary.
onClick={() => setOpen((o) => !o)} label="How much of this session isn't in your graph yet — click for details"
aria-expanded={open} side="bottom"
title="Extraction coverage — uncovered tokens vs the chunk threshold"
className="relative flex size-5 cursor-pointer items-center justify-center transition-opacity hover:opacity-80"
> >
{/* The donut: track + fill. The whole ring pulses while a run <button
is in flight (motion = the working-state signal). */} type="button"
<CoverageRingGlyph fill={fill} running={coverage.running} /> data-testid="coverage-ring"
</button> onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="relative flex size-5 cursor-pointer items-center justify-center transition-opacity hover:opacity-80"
>
{/* The donut: track + fill. The whole ring pulses while a run
is in flight (motion = the working-state signal). */}
<CoverageRingGlyph fill={fill} running={coverage.running} />
</button>
</Hint>
{open && ( {open && (
<div <div
data-testid="coverage-ring-popover" data-testid="coverage-ring-popover"
+23 -4
View File
@@ -70,6 +70,7 @@ import { usePopoverDismiss } from "@/components/popover-dismiss";
import { useActivity } from "@/components/activity-dock"; import { useActivity } from "@/components/activity-dock";
import { MenuShell } from "@/components/menu-shell.tsx"; import { MenuShell } from "@/components/menu-shell.tsx";
import { DeleteNodeDialog } from "@/components/delete-node-dialog.tsx"; import { DeleteNodeDialog } from "@/components/delete-node-dialog.tsx";
import { Hint } from "@/components/ui/hint";
import { import {
ARROW_LENGTH_LADDER, ARROW_LENGTH_LADDER,
CHARGE_RANGE, CHARGE_RANGE,
@@ -326,9 +327,13 @@ function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
usePopoverDismiss(rootRef, open, () => setOpen(false)); usePopoverDismiss(rootRef, open, () => setOpen(false));
return ( return (
<div ref={rootRef} className="relative"> <div ref={rootRef} className="relative">
<button <Hint
// Plain language (tooltip wave) — no internal vocabulary.
label="What the graph has spent — click for details"
side="bottom"
>
<button
type="button" type="button"
title="Graph pipeline costs — click for the breakdown"
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
className={`rounded-lg px-2 py-1 text-xs tabular-nums transition-colors hover:bg-accent ${ className={`rounded-lg px-2 py-1 text-xs tabular-nums transition-colors hover:bg-accent ${
@@ -337,6 +342,7 @@ function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
> >
${displayCostTotals(costs).totalDollars.toFixed(2)} ${displayCostTotals(costs).totalDollars.toFixed(2)}
</button> </button>
</Hint>
{open && ( {open && (
<div className="absolute right-0 top-full mt-2 max-h-[60vh] overflow-y-auto"> <div className="absolute right-0 top-full mt-2 max-h-[60vh] overflow-y-auto">
<GraphCostPanel costs={costs} /> <GraphCostPanel costs={costs} />
@@ -437,9 +443,13 @@ function GraphSettingsControl({
const physicsTuning = settings.physics; const physicsTuning = settings.physics;
return ( return (
<div ref={rootRef} className="relative"> <div ref={rootRef} className="relative">
<button <Hint
// User language (tooltip wave) — "rendering" is internal.
label="Graph display settings"
side="bottom"
>
<button
type="button" type="button"
title="Rendering settings"
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
className={`flex size-9 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${ className={`flex size-9 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${
@@ -448,6 +458,7 @@ function GraphSettingsControl({
> >
<SettingsIcon className="size-4" /> <SettingsIcon className="size-4" />
</button> </button>
</Hint>
{open && ( {open && (
// Viewport-capped + internally scrollable — the coverage // Viewport-capped + internally scrollable — the coverage
// dropdown's exact pattern (owner, 2026-09-17): max-h keeps a // dropdown's exact pattern (owner, 2026-09-17): max-h keeps a
@@ -835,6 +846,12 @@ export function GraphChromeRow({
<> <>
{!isEmpty && ( {!isEmpty && (
<div className="graph-chrome-fade-in w-56 min-w-0 shrink rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur"> <div className="graph-chrome-fade-in w-56 min-w-0 shrink rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<Hint
// ⌘F focuses node search (surface-scoped registry action).
label="Search nodes"
action="find"
side="bottom"
>
<div className="relative"> <div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" /> <SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input <input
@@ -842,10 +859,12 @@ export function GraphChromeRow({
value={query} value={query}
onChange={(e) => onQuery(e.target.value)} onChange={(e) => onQuery(e.target.value)}
placeholder="Search nodes…" placeholder="Search nodes…"
aria-label="Search nodes"
spellCheck={false} spellCheck={false}
className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
/> />
</div> </div>
</Hint>
{noMatch && ( {noMatch && (
<div className="px-1.5 pt-1 text-[11px] text-muted-foreground">No matching nodes.</div> <div className="px-1.5 pt-1 text-[11px] text-muted-foreground">No matching nodes.</div>
)} )}
+9 -1
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { CornerDownLeftIcon } from "lucide-react"; import { CornerDownLeftIcon } from "lucide-react";
import { GraphView } from "@/components/graph-view"; import { GraphView } from "@/components/graph-view";
import { SetupDialog } from "@/components/setup-dialog"; import { SetupDialog } from "@/components/setup-dialog";
import { Hint } from "@/components/ui/hint";
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
import { useDefaultSettings } from "@/components/default-settings"; import { useDefaultSettings } from "@/components/default-settings";
import { DictationBar, useDictation } from "@/components/dictation"; import { DictationBar, useDictation } from "@/components/dictation";
@@ -176,7 +177,13 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings:
rows={1} rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/> />
<button <Hint
// Enter starts the session too (the form submits).
label="Start session"
binding="enter"
side="left"
>
<button
type="submit" type="submit"
aria-label="Start session" aria-label="Start session"
disabled={!topic.trim() || busy} disabled={!topic.trim() || busy}
@@ -184,6 +191,7 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings:
> >
<CornerDownLeftIcon className="size-5" /> <CornerDownLeftIcon className="size-5" />
</button> </button>
</Hint>
</form> </form>
{/* Same as the chat view's under-composer stats bar (owner) — no {/* Same as the chat view's under-composer stats bar (owner) — no
session yet, so the LEFT side renders nothing (wave 7 sweep) session yet, so the LEFT side renders nothing (wave 7 sweep)
+10 -3
View File
@@ -40,6 +40,7 @@ import type { ExtraProps } from "streamdown";
import { MERMAID_FONT_SIZE, renderMermaid } from "@/components/mermaid-render"; import { MERMAID_FONT_SIZE, renderMermaid } from "@/components/mermaid-render";
import { MermaidError } from "@/components/mermaid-error"; import { MermaidError } from "@/components/mermaid-error";
import { chartSize, useSessionMermaid } from "@/components/session-overlay"; import { chartSize, useSessionMermaid } from "@/components/session-overlay";
import { Hint } from "@/components/ui/hint";
// ─── The one affordance: the hover-reveal corner copy ──────────────────────── // ─── The one affordance: the hover-reveal corner copy ────────────────────────
@@ -198,16 +199,22 @@ function MermaidInline({ chart }: { chart: string }) {
<div className="absolute top-0 right-0 flex items-start gap-0.5"> <div className="absolute top-0 right-0 flex items-start gap-0.5">
<CopyCorner getText={() => chart} label="Copy diagram source" /> <CopyCorner getText={() => chart} label="Copy diagram source" />
{overlay && ( {overlay && (
<button <Hint
// Full-screen overlay copy (tooltip wave) — "session view" was
// internal direction-speak for the same thing.
label="Open full-screen"
side="bottom"
>
<button
type="button" type="button"
aria-label="Expand diagram" aria-label="Expand diagram"
data-testid="md-mermaid-expand" data-testid="md-mermaid-expand"
title="Expand into the session view" className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/md:opacity-100"
className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/md:opacity-100"
onClick={() => overlay.openMermaid(chart)} onClick={() => overlay.openMermaid(chart)}
> >
<MaximizeIcon className="size-3.5" /> <MaximizeIcon className="size-3.5" />
</button> </button>
</Hint>
)} )}
</div> </div>
</div> </div>
+17 -4
View File
@@ -7,6 +7,7 @@ import { prettyModel } from "@/components/usage-format";
import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu"; import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu";
import { composeSlotModel } from "@/components/settings/agent-section"; import { composeSlotModel } from "@/components/settings/agent-section";
import { MenuShell } from "@/components/menu-shell"; import { MenuShell } from "@/components/menu-shell";
import { Hint } from "@/components/ui/hint";
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
@@ -252,12 +253,18 @@ export function ModelControl() {
return ( return (
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control"> <div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
<button <Hint
// User language (tooltip wave): no tier/prober vocabulary. ⌘K
// opens this same menu (registry, override-aware).
label="The main model for your sessions"
action="open-model-picker"
side="bottom"
>
<button
type="button" type="button"
data-testid="stats-model" data-testid="stats-model"
onClick={() => openMenu("model")} onClick={() => openMenu("model")}
aria-expanded={open === "model"} aria-expanded={open === "model"}
title="Strong tier model (prober + teacher) — set app-wide"
// Chrome look (owner, fix wave round 3): matches the graph // Chrome look (owner, fix wave round 3): matches the graph
// view's floating top-right buttons (import notes, gear, // view's floating top-right buttons (import notes, gear,
// coverage) — blurred, fuller opacity, shadowed — so the // coverage) — blurred, fuller opacity, shadowed — so the
@@ -267,7 +274,13 @@ export function ModelControl() {
<span className="text-card-foreground">{effectiveId ? prettyModel(effectiveId) : "—"}</span> <span className="text-card-foreground">{effectiveId ? prettyModel(effectiveId) : "—"}</span>
<span aria-hidden className="text-xs text-muted-foreground">▾</span> <span aria-hidden className="text-xs text-muted-foreground">▾</span>
</button> </button>
<button </Hint>
<Hint
// User language (tooltip wave) — no "tier" vocabulary.
label="How much the main model thinks"
side="bottom"
>
<button
type="button" type="button"
data-testid="stats-thinking" data-testid="stats-thinking"
onClick={() => { onClick={() => {
@@ -277,7 +290,6 @@ export function ModelControl() {
openMenu("thinking"); openMenu("thinking");
}} }}
aria-expanded={open === "thinking"} aria-expanded={open === "thinking"}
title="Strong tier thinking level — set app-wide"
// Chrome look (owner, fix wave round 3): matches the graph // Chrome look (owner, fix wave round 3): matches the graph
// view's floating top-right buttons (import notes, gear, // view's floating top-right buttons (import notes, gear,
// coverage) — blurred, fuller opacity, shadowed — so the // coverage) — blurred, fuller opacity, shadowed — so the
@@ -291,6 +303,7 @@ export function ModelControl() {
</span> </span>
<span aria-hidden className="text-xs text-muted-foreground">▾</span> <span aria-hidden className="text-xs text-muted-foreground">▾</span>
</button> </button>
</Hint>
{/* The shared dropdown body (extracted 2026-09-20): the agent {/* The shared dropdown body (extracted 2026-09-20): the agent
settings' slot selects and the setup dialog use the exact same settings' slot selects and the setup dialog use the exact same
+17 -9
View File
@@ -15,6 +15,7 @@ import { curveBasis, line } from "d3-shape";
import { fitGraphView, fitZoomFor, zoomedGraphView, type Viewport } from "@/components/pan-zoom"; import { fitGraphView, fitZoomFor, zoomedGraphView, type Viewport } from "@/components/pan-zoom";
import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core"; import type { PlanBlockData, PlanNodeStatus, PlanTree } from "@/src/core";
import { derivedNodeStatus, derivePlanStatuses } from "@/src/core"; import { derivedNodeStatus, derivePlanStatuses } from "@/src/core";
import { Hint } from "@/components/ui/hint";
/** /**
* PLAN BLOCKS — the plan lives in the chat (wave 4): persisted * PLAN BLOCKS — the plan lives in the chat (wave 4): persisted
@@ -542,11 +543,12 @@ export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () =>
const statusMap = useMemo(() => new Map(data.nodes.map((n) => [n.id, n.status] as const)), [data]); const statusMap = useMemo(() => new Map(data.nodes.map((n) => [n.id, n.status] as const)), [data]);
const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming"; const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming";
return ( return (
<button <Hint label="Open the full plan" side="bottom">
<button
type="button" type="button"
data-testid="plan-block-full" data-testid="plan-block-full"
aria-label="Open the full plan"
onClick={onOpen} onClick={onOpen}
title="Open the full plan"
className="block w-full text-left" className="block w-full text-left"
> >
{/* pad keeps centered strokes + the glow halo inside the frame; the {/* pad keeps centered strokes + the glow halo inside the frame; the
@@ -562,6 +564,7 @@ export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () =>
monochrome monochrome
/> />
</button> </button>
</Hint>
); );
} }
@@ -745,7 +748,8 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium">The plan</span> <span className="text-sm font-medium">The plan</span>
<span className="text-xs text-muted-foreground">live — statuses derive from your check outcomes</span> <span className="text-xs text-muted-foreground">live — statuses derive from your check outcomes</span>
<button <Hint label="Close" binding="escape" side="left">
<button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label="Close"
@@ -754,6 +758,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
> >
<XIcon className="size-4" /> <XIcon className="size-4" />
</button> </button>
</Hint>
</div> </div>
<div <div
ref={viewportRef} ref={viewportRef}
@@ -797,18 +802,19 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2"> <div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span> <span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
<div className="flex gap-1"> <div className="flex gap-1">
<button <Hint label="Zoom out" side="bottom">
<button
type="button" type="button"
title="Zoom out"
aria-label="Zoom out" aria-label="Zoom out"
onClick={() => stepZoom(1 / 1.25)} onClick={() => stepZoom(1 / 1.25)}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground" className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
> >
<MinusIcon className="size-3.5" /> <MinusIcon className="size-3.5" />
</button> </button>
<button </Hint>
<Hint label="Fit plan to screen" side="bottom">
<button
type="button" type="button"
title="Fit plan to screen"
aria-label="Fit plan to screen" aria-label="Fit plan to screen"
onClick={fitView} onClick={fitView}
data-testid="plan-popup-fit" data-testid="plan-popup-fit"
@@ -816,15 +822,17 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
> >
<MaximizeIcon className="size-3.5" /> <MaximizeIcon className="size-3.5" />
</button> </button>
<button </Hint>
<Hint label="Zoom in" side="bottom">
<button
type="button" type="button"
title="Zoom in"
aria-label="Zoom in" aria-label="Zoom in"
onClick={() => stepZoom(1.25)} onClick={() => stepZoom(1.25)}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground" className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
> >
<PlusIcon className="size-3.5" /> <PlusIcon className="size-3.5" />
</button> </button>
</Hint>
</div> </div>
</div> </div>
</> </>
+13 -7
View File
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
import { fitGraphView, fitZoomFor, zoomedGraphView, type Viewport } from "@/components/pan-zoom"; import { fitGraphView, fitZoomFor, zoomedGraphView, type Viewport } from "@/components/pan-zoom";
import { renderMermaid } from "@/components/mermaid-render"; import { renderMermaid } from "@/components/mermaid-render";
import { MermaidError } from "@/components/mermaid-error"; import { MermaidError } from "@/components/mermaid-error";
import { Hint } from "@/components/ui/hint";
/** /**
* SESSION-SCOPED OVERLAYS (owner wave, 2026-09-19): the "fullscreen" * SESSION-SCOPED OVERLAYS (owner wave, 2026-09-19): the "fullscreen"
@@ -181,7 +182,8 @@ export function MermaidOverlay({ chart, onClose }: { chart: string; onClose: ()
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium">Diagram</span> <span className="text-sm font-medium">Diagram</span>
<span className="text-xs text-muted-foreground">from this lesson</span> <span className="text-xs text-muted-foreground">from this lesson</span>
<button <Hint label="Close" binding="escape" side="left">
<button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label="Close"
@@ -190,6 +192,7 @@ export function MermaidOverlay({ chart, onClose }: { chart: string; onClose: ()
> >
<XIcon className="size-4" /> <XIcon className="size-4" />
</button> </button>
</Hint>
</div> </div>
<div <div
ref={viewportRef} ref={viewportRef}
@@ -238,33 +241,36 @@ export function MermaidOverlay({ chart, onClose }: { chart: string; onClose: ()
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2"> <div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span> <span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
<div className="flex gap-1"> <div className="flex gap-1">
<button <Hint label="Zoom out" side="bottom">
<button
type="button" type="button"
title="Zoom out"
aria-label="Zoom out" aria-label="Zoom out"
onClick={() => stepZoom(1 / 1.25)} onClick={() => stepZoom(1 / 1.25)}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground" className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
> >
<MinusIcon className="size-3.5" /> <MinusIcon className="size-3.5" />
</button> </button>
<button </Hint>
<Hint label="Fit diagram to view" side="bottom">
<button
type="button" type="button"
title="Fit diagram to view"
aria-label="Fit diagram to view" aria-label="Fit diagram to view"
onClick={fitView} onClick={fitView}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground" className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
> >
<MaximizeIcon className="size-3.5" /> <MaximizeIcon className="size-3.5" />
</button> </button>
<button </Hint>
<Hint label="Zoom in" side="bottom">
<button
type="button" type="button"
title="Zoom in"
aria-label="Zoom in" aria-label="Zoom in"
onClick={() => stepZoom(1.25)} onClick={() => stepZoom(1.25)}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground" className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
> >
<PlusIcon className="size-3.5" /> <PlusIcon className="size-3.5" />
</button> </button>
</Hint>
</div> </div>
</div> </div>
</> </>
+10 -10
View File
@@ -62,9 +62,9 @@ import { ModelMenuList, ThinkingMenuList } from "./model-menu";
/** One slot's display identity — the tier's roster as the row hint rides /** One slot's display identity — the tier's roster as the row hint rides
* with the resolved default (owner: the hint ALWAYS names the default). */ * with the resolved default (owner: the hint ALWAYS names the default). */
const SLOT_META: Record<ModelSlotName, { label: string; roster: string }> = { const SLOT_META: Record<ModelSlotName, { label: string; roster: string }> = {
strong: { label: "Strong", roster: "Prober + Teacher" }, strong: { label: "Strong", roster: "The main model for your sessions" },
cheap: { label: "Cheap", roster: "Researcher, demo builder, extractor, auditor" }, cheap: { label: "Cheap", roster: "The assistants that research, build demos, and grade your work" },
vision: { label: "Vision", roster: "Visual maker + PDF reader" }, vision: { label: "Vision", roster: "The assistants that draw and read PDFs" },
}; };
/** The search engines this wave ships (src/core/search.ts) + their labels. */ /** The search engines this wave ships (src/core/search.ts) + their labels. */
@@ -403,7 +403,7 @@ export function AgentSection({
<div key={info.path} data-testid={`prompt-segment-${info.path}`}> <div key={info.path} data-testid={`prompt-segment-${info.path}`}>
<SettingsRow <SettingsRow
label={info.path} label={info.path}
hint={fileExists ? undefined : "no override file yet — write one below"} hint={fileExists ? undefined : "No changes yet — add yours below"}
testid={`prompt-row-${info.path}`} testid={`prompt-row-${info.path}`}
dimmed={!fileExists} dimmed={!fileExists}
> >
@@ -434,7 +434,7 @@ export function AgentSection({
<textarea <textarea
readOnly readOnly
value={info.sourceText} value={info.sourceText}
aria-label={`source ${info.path}`} aria-label="The original instructions"
className="h-24 w-full resize-y rounded-sm border border-border bg-muted p-2 font-ui text-xs text-muted-foreground" className="h-24 w-full resize-y rounded-sm border border-border bg-muted p-2 font-ui text-xs text-muted-foreground"
/> />
</div> </div>
@@ -443,8 +443,8 @@ export function AgentSection({
<textarea <textarea
value={segmentDraft[info.path] ?? info.overrideText ?? ""} value={segmentDraft[info.path] ?? info.overrideText ?? ""}
onChange={(e) => setSegmentDraft({ ...segmentDraft, [info.path]: e.target.value })} onChange={(e) => setSegmentDraft({ ...segmentDraft, [info.path]: e.target.value })}
aria-label={`override ${info.path}`} aria-label="Your version of the instructions"
placeholder="Write the override text…" placeholder="Add your own instructions…"
className="h-24 w-full resize-y rounded-sm border border-border bg-background p-2 font-ui text-xs text-foreground outline-none" className="h-24 w-full resize-y rounded-sm border border-border bg-background p-2 font-ui text-xs text-foreground outline-none"
/> />
</div> </div>
@@ -454,7 +454,7 @@ export function AgentSection({
onClick={() => void saveSegment(info.path, segmentDraft[info.path] ?? "")} onClick={() => void saveSegment(info.path, segmentDraft[info.path] ?? "")}
className="text-xs text-foreground underline-offset-2 hover:underline" className="text-xs text-foreground underline-offset-2 hover:underline"
> >
Save override Save
</button> </button>
{fileExists && ( {fileExists && (
<button <button
@@ -462,7 +462,7 @@ export function AgentSection({
onClick={() => void deleteSegment(info.path)} onClick={() => void deleteSegment(info.path)}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline" className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
> >
Delete override Delete
</button> </button>
)} )}
</div> </div>
@@ -597,7 +597,7 @@ export function AgentSection({
return ( return (
<SettingsRow <SettingsRow
label="Graph tools" label="Graph tools"
hint={graphOn ? "Connects prior sessions' knowledge into new ones — the prober reads the knowledge map" : "Graph is disabled"} hint={graphOn ? "Lets the tutor consult your knowledge map between sessions" : "Graph is disabled"}
testid="graph-tools-row" testid="graph-tools-row"
dimmed={!graphOn} dimmed={!graphOn}
> >
+1 -1
View File
@@ -34,7 +34,7 @@ export function AppearanceSection({
return ( return (
<> <>
<SettingsRow label="Mode" hint="Light ships with the design wave" testid="row-mode" dimmed> <SettingsRow label="Mode" hint="Coming soon" testid="row-mode" dimmed>
<ThinSelect value="dark" ariaLabel="mode" onChange={() => {}} testid="select-mode" disabled> <ThinSelect value="dark" ariaLabel="mode" onChange={() => {}} testid="select-mode" disabled>
<option value="dark">Dark</option> <option value="dark">Dark</option>
</ThinSelect> </ThinSelect>
+1 -1
View File
@@ -109,7 +109,7 @@ export function KeyboardSection() {
<SettingsRow <SettingsRow
key={def.id} key={def.id}
label={def.label} label={def.label}
hint={def.displayOnly ? "Owned by a surface — not rebindable" : undefined} hint={def.displayOnly ? "Fixed — part of this screen" : undefined}
testid={`kbd-row-${def.id}`} testid={`kbd-row-${def.id}`}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
+14 -2
View File
@@ -44,6 +44,7 @@ import type { ModelsSettings } from "@/src/core";
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "@/components/menu-shell"; import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "@/components/menu-shell";
import { notifySettingsSaved } from "@/components/settings-signal"; import { notifySettingsSaved } from "@/components/settings-signal";
import { KeysSection } from "./keys-section.tsx"; import { KeysSection } from "./keys-section.tsx";
import { Hint } from "@/components/ui/hint";
import { AppearanceSection, GraphSection } from "./appearance-section.tsx"; import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
import { KeyboardSection } from "./keyboard-section.tsx"; import { KeyboardSection } from "./keyboard-section.tsx";
@@ -70,6 +71,10 @@ const DialogContext = createContext<{ open: (section?: string, row?: string) =>
/** AppShell-level mount: the sidebar row and the mod+, chord call this. /** AppShell-level mount: the sidebar row and the mod+, chord call this.
* `open(section)` lands the dialog on a specific section (the first-run * `open(section)` lands the dialog on a specific section (the first-run
* setup flow deep-links to Keys when no provider key exists yet). */ * setup flow deep-links to Keys when no provider key exists yet). */
/** The auto-update row's hint (tooltip wave): honest user language — no
* stream vocabulary. Exported for the copy pin (tooltip-mechanism test). */
export const AUTO_UPDATE_HINT = "Automatic updates arrive in an upcoming release";
export function SettingsDialogProvider({ export function SettingsDialogProvider({
children, children,
}: { }: {
@@ -258,7 +263,13 @@ export function SettingsDialogBody({
{/* Header — the app's quiet CMU title language. */} {/* Header — the app's quiet CMU title language. */}
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5"> <header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5">
<h2 className="font-title text-2xl tracking-tight">Settings</h2> <h2 className="font-title text-2xl tracking-tight">Settings</h2>
<button <Hint
// Esc closes the dialog (its own listener) — surface it.
label="Close"
binding="escape"
side="left"
>
<button
type="button" type="button"
aria-label="Close settings" aria-label="Close settings"
data-testid="settings-close" data-testid="settings-close"
@@ -275,6 +286,7 @@ export function SettingsDialogBody({
<path d="M4 4l8 8M12 4l-8 8" /> <path d="M4 4l8 8M12 4l-8 8" />
</svg> </svg>
</button> </button>
</Hint>
</header> </header>
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
@@ -340,7 +352,7 @@ export function SettingsDialogBody({
> >
<SettingsRow <SettingsRow
label="Auto-update" label="Auto-update"
hint="The update mechanism ships in a later wave" hint={AUTO_UPDATE_HINT}
testid="row-auto-update" testid="row-auto-update"
> >
<Toggle <Toggle
+4 -1
View File
@@ -39,6 +39,7 @@ import { prettyModel } from "@/components/usage-format";
import { ModelMenuList } from "@/components/settings/model-menu"; import { ModelMenuList } from "@/components/settings/model-menu";
import { MenuShell } from "@/components/menu-shell"; import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
import { Hint } from "@/components/ui/hint";
import { type ProviderKeyInfo } from "@/components/settings/keys-section"; import { type ProviderKeyInfo } from "@/components/settings/keys-section";
import { ProviderMenuList } from "@/components/settings/model-menu"; import { ProviderMenuList } from "@/components/settings/model-menu";
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
@@ -397,7 +398,8 @@ export function SetupDialog({
> >
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5"> <header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5">
<h2 className="font-title text-2xl tracking-tight">Set up your models</h2> <h2 className="font-title text-2xl tracking-tight">Set up your models</h2>
<button <Hint label="Close" binding="escape" side="left">
<button
type="button" type="button"
aria-label="Close setup" aria-label="Close setup"
data-testid="setup-close" data-testid="setup-close"
@@ -406,6 +408,7 @@ export function SetupDialog({
> >
✕ ✕
</button> </button>
</Hint>
</header> </header>
{stage === "keys" ? ( {stage === "keys" ? (
+37 -26
View File
@@ -13,6 +13,7 @@ import { LhMark } from "@/components/lh-mark";
import { BrailleSpinner } from "@/components/braille-spinner"; import { BrailleSpinner } from "@/components/braille-spinner";
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts"; import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
import { SettingsDialogProvider, useSettingsDialog } from "@/components/settings/settings-dialog.tsx"; import { SettingsDialogProvider, useSettingsDialog } from "@/components/settings/settings-dialog.tsx";
import { Hint } from "@/components/ui/hint";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -154,6 +155,7 @@ export function SidebarToggle() {
open ? "w-0 -mr-3" : "w-9 mr-0 lh-closed-toggle", open ? "w-0 -mr-3" : "w-9 mr-0 lh-closed-toggle",
)} )}
> >
<Hint label="Open sidebar" action="toggle-sidebar">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -168,6 +170,7 @@ export function SidebarToggle() {
The small look is the standard, both directions. */} The small look is the standard, both directions. */}
<PanelLeftOpenIcon className="size-4" /> <PanelLeftOpenIcon className="size-4" />
</Button> </Button>
</Hint>
</span> </span>
); );
} }
@@ -541,22 +544,26 @@ function SessionsSidebar() {
with the lights' own edge-inset as the gap. */} with the lights' own edge-inset as the gap. */}
{/* Hover mirrors the ghost toggle buttons (owner ask, 2026-09-17): {/* Hover mirrors the ghost toggle buttons (owner ask, 2026-09-17):
same size-9 chip geometry, same hover:bg-accent treatment. */} same size-9 chip geometry, same hover:bg-accent treatment. */}
<Link <Hint label="Home" side="bottom">
href="/" <Link
aria-label="Home" href="/"
className="lh-no-drag flex size-9 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50" aria-label="Home"
> className="lh-no-drag flex size-9 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
<LhMark className="size-6" /> >
</Link> <LhMark className="size-6" />
<Button </Link>
variant="ghost" </Hint>
size="icon" <Hint label="Close sidebar" action="toggle-sidebar">
aria-label="Close sidebar" <Button
onClick={toggle} variant="ghost"
className="lh-no-drag" size="icon"
> aria-label="Close sidebar"
<PanelLeftCloseIcon className="size-4" /> onClick={toggle}
</Button> className="lh-no-drag"
>
<PanelLeftCloseIcon className="size-4" />
</Button>
</Hint>
</div> </div>
<button <button
@@ -572,6 +579,7 @@ function SessionsSidebar() {
<div className="mx-3 mb-1 flex items-center gap-2"> <div className="mx-3 mb-1 flex items-center gap-2">
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span> <span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
<Hint label="Search sessions" action="sessions-search" side="bottom">
<span className="relative flex-1"> <span className="relative flex-1">
<Input <Input
ref={searchRef} ref={searchRef}
@@ -587,12 +595,13 @@ function SessionsSidebar() {
e.currentTarget.blur(); e.currentTarget.blur();
} }
}} }}
placeholder="Search..." placeholder="Search…"
aria-label="Search sessions" aria-label="Search sessions"
className="h-6 rounded-sm border-border bg-background pl-6 text-xs placeholder:text-muted-foreground/60" className="h-6 rounded-sm border-border bg-background pl-6 text-xs placeholder:text-muted-foreground/60"
/> />
<SearchIcon className="pointer-events-none absolute left-1.5 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/70" /> <SearchIcon className="pointer-events-none absolute left-1.5 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/70" />
</span> </span>
</Hint>
</div> </div>
<div ref={listRef} className="flex-1 overflow-y-auto p-2"> <div ref={listRef} className="flex-1 overflow-y-auto p-2">
@@ -639,15 +648,17 @@ function SessionsSidebar() {
New session — quiet, bottom-pinned, opens the centered New session — quiet, bottom-pinned, opens the centered
dialog (also mod+, in the keyboard registry). */} dialog (also mod+, in the keyboard registry). */}
<div className="px-3 pb-3 pt-1"> <div className="px-3 pb-3 pt-1">
<button <Hint label="Settings" action="open-settings" side="right">
type="button" <button
onClick={() => openSettings()} type="button"
data-testid="settings-entry" onClick={() => openSettings()}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" data-testid="settings-entry"
> className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
<SettingsIcon className="size-3" /> >
Settings <SettingsIcon className="size-3" />
</button> Settings
</button>
</Hint>
</div> </div>
</div> </div>
+9 -2
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core"; import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format"; import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker"; import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
import { Hint } from "@/components/ui/hint";
/** /**
* The below-composer stats bar (wave 4 observability): the session's * The below-composer stats bar (wave 4 observability): the session's
@@ -92,11 +93,16 @@ export function StatsBar({
exists (turns). Pre-chat renders nothing (see the header). */} exists (turns). Pre-chat renders nothing (see the header). */}
{turns.length > 0 && ( {turns.length > 0 && (
<div ref={barRef} className="relative"> <div ref={barRef} className="relative">
<button <Hint
// Copy kept (the scout approved) — machinery moved to the
// styled tooltip (tooltip wave).
label="Session stats — per-turn detail"
side="bottom"
>
<button
type="button" type="button"
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
aria-expanded={open} aria-expanded={open}
title="Session stats — per-turn detail"
data-testid="stats-summary" data-testid="stats-summary"
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground" className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
> >
@@ -106,6 +112,7 @@ export function StatsBar({
<span aria-hidden>·</span> <span aria-hidden>·</span>
<span data-testid="stats-cost">{costLabel}</span> <span data-testid="stats-cost">{costLabel}</span>
</button> </button>
</Hint>
{open && ( {open && (
<div <div
data-testid="stats-detail" data-testid="stats-detail"
+54
View File
@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
import { bindingForAction, formatBinding, type KeyAction } from "@/src/core"
/**
* The tooltip composition for icon and pill controls (tooltip wave, owner
* 2026-09-25). The styled Radix tooltip (ui/tooltip.tsx, adopted as-is —
* no restyling) carries the label, with the control's shortcut appended as
* an em-dash suffix when it has one ("Dictate — ⌘D").
*
* The binding is override-aware: pass `action` for registry shortcuts (a
* user rebind in Settings → Keyboard changes the suffix live), or
* `binding` for surface-owned keys with no registry entry ("escape" for a
* dialog close, "enter" for a form submit).
*
* children must be a single element able to take a ref (TooltipTrigger
* asChild merges onto it — no wrapper DOM, no layout shift).
*/
interface HintProps {
label: string
/** Registry action whose LIVE binding is shown (override-aware). */
action?: KeyAction
/** Surface-owned binding ("escape", "enter") — keys the registry
* deliberately doesn't dispatch (Esc closes the dialog itself; Enter
* submits the form the button belongs to). */
binding?: string
side?: "top" | "bottom" | "left" | "right"
children: React.ReactElement
}
/** The composition itself, pure — "Dictate" + ⌘D → "Dictate — ⌘D". Exported
* for the mechanism pins (tests/tooltip-mechanism.test.tsx). */
export function hintText(label: string, action?: KeyAction, binding?: string): string {
const live = action
? formatBinding(bindingForAction(action))
: binding
? formatBinding(binding)
: ""
return live ? `${label} — ${live}` : label
}
export function Hint({ label, action, binding, side = "top", children }: HintProps) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side}>{hintText(label, action, binding)}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
+4
View File
@@ -204,6 +204,10 @@ describe("activity section render", () => {
assert.match(html, /Background agents/); assert.match(html, /Background agents/);
// Total cost: nothing priced → "—" in the header. // Total cost: nothing priced → "—" in the header.
assert.match(html, /activity-total-cost[^>]*>—</); assert.match(html, /activity-total-cost[^>]*>—</);
// Truncation-reveals KEEP native title (tooltip wave mechanism rule):
// the truncated activity + title spans carry title= — a styled
// tooltip adds nothing over an ellipsis reveal.
assert.match(html, /title="building sandbox…"/);
}); });
+4 -2
View File
@@ -39,7 +39,9 @@ describe("GraphChromeRow — the empty-graph ruling (owner, 2026-09-25)", () =>
assert.ok(html.includes('data-testid="import-notes"')); assert.ok(html.includes('data-testid="import-notes"'));
assert.ok(html.includes("Import notes…")); assert.ok(html.includes("Import notes…"));
assert.ok(!html.includes("Search nodes…"), "search is a lie on an empty graph"); assert.ok(!html.includes("Search nodes…"), "search is a lie on an empty graph");
assert.ok(!html.includes('title="Rendering settings"'), "settings is a lie on an empty graph"); // the settings gear is the lucide-settings svg (tooltip wave: its old
// native title is gone — the styled tooltip carries the label now)
assert.ok(!html.includes("lucide-settings"), "settings is a lie on an empty graph");
assert.ok(!html.includes("graph-chrome-fade-in"), "nothing gated = nothing fading in"); assert.ok(!html.includes("graph-chrome-fade-in"), "nothing gated = nothing fading in");
}); });
@@ -47,7 +49,7 @@ describe("GraphChromeRow — the empty-graph ruling (owner, 2026-09-25)", () =>
const html = renderToStaticMarkup(createElement(GraphChromeRow, { ...PROPS, isEmpty: false })); const html = renderToStaticMarkup(createElement(GraphChromeRow, { ...PROPS, isEmpty: false }));
assert.ok(html.includes('data-testid="import-notes"')); assert.ok(html.includes('data-testid="import-notes"'));
assert.ok(html.includes("Search nodes…")); assert.ok(html.includes("Search nodes…"));
assert.ok(html.includes('title="Rendering settings"')); assert.ok(html.includes("lucide-settings"), "the settings gear renders");
// the appear-once fade rides exactly the two gated controls, never Import Notes // the appear-once fade rides exactly the two gated controls, never Import Notes
assert.equal(html.split("graph-chrome-fade-in").length - 1, 2, "two fade carriers: search + settings"); assert.equal(html.split("graph-chrome-fade-in").length - 1, 2, "two fade carriers: search + settings");
}); });
+1 -1
View File
@@ -140,7 +140,7 @@ describe("plan block render", () => {
// Snapshot statuses render as carried. // Snapshot statuses render as carried.
assert.match(html, /data-testid="plan-node-a" data-status="visited"/); assert.match(html, /data-testid="plan-node-a" data-status="visited"/);
assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/); assert.match(html, /data-testid="plan-node-b" data-status="upcoming"/);
assert.match(html, /Open the full plan/); assert.ok(html.includes('aria-label="Open the full plan"'), "the trigger names itself (tooltip copy)");
}); });
}); });
+4 -4
View File
@@ -69,7 +69,7 @@ describe("AgentSection (thin SSR)", () => {
assert.ok(html.includes(`data-testid="slot-model-${slot}"`), `${slot} model trigger`); assert.ok(html.includes(`data-testid="slot-model-${slot}"`), `${slot} model trigger`);
assert.ok(html.includes(`data-testid="slot-thinking-${slot}"`), `${slot} thinking trigger`); assert.ok(html.includes(`data-testid="slot-thinking-${slot}"`), `${slot} thinking trigger`);
// the roster hint rides every row // the roster hint rides every row
assert.ok(html.includes("Prober + Teacher") || slot !== "strong", "strong roster"); assert.ok(html.includes("The main model for your sessions") || slot !== "strong", "strong roster");
} }
// Fix wave: no Default anywhere — an unpicked slot reads "—" // Fix wave: no Default anywhere — an unpicked slot reads "—"
// (the owner's no-factory-fallback language), and no menu // (the owner's no-factory-fallback language), and no menu
@@ -107,7 +107,7 @@ describe("AgentSection (thin SSR)", () => {
const rows = [...toolsGroup.matchAll(/data-testid="(graph-tools-row|tool-row-\w+)"/g)].map((m) => m[1]); const rows = [...toolsGroup.matchAll(/data-testid="(graph-tools-row|tool-row-\w+)"/g)].map((m) => m[1]);
assert.equal(rows[0], "graph-tools-row", "graph tools is the FIRST row of the Tools group"); assert.equal(rows[0], "graph-tools-row", "graph tools is the FIRST row of the Tools group");
// (the apostrophe in "sessions'" HTML-escapes — match up to it) // (the apostrophe in "sessions'" HTML-escapes — match up to it)
assert.ok(html.includes("Connects prior sessions"), "the describing hint"); assert.ok(html.includes("Lets the tutor consult your knowledge map between sessions"), "the describing hint");
assert.ok((html.split('data-testid="graph-tools-row"')[1] ?? "").includes('aria-checked="true"'), "absent = on"); assert.ok((html.split('data-testid="graph-tools-row"')[1] ?? "").includes('aria-checked="true"'), "absent = on");
const rowHtml = html.split('data-testid="graph-tools-row"')[1]?.split('data-testid="tool-row-')[0] ?? ""; const rowHtml = html.split('data-testid="graph-tools-row"')[1]?.split('data-testid="tool-row-')[0] ?? "";
assert.ok(!rowHtml.includes("opacity-40"), "lit while the graph is on (the search row may dim — no keys in SSR)"); assert.ok(!rowHtml.includes("opacity-40"), "lit while the graph is on (the search row may dim — no keys in SSR)");
@@ -198,7 +198,7 @@ describe("AppearanceSection (thin SSR)", () => {
it("mode is rendered DISABLED — dark-only this wave, no lying control", () => { it("mode is rendered DISABLED — dark-only this wave, no lying control", () => {
const html = renderToStaticMarkup(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />); const html = renderToStaticMarkup(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />);
assert.ok(tagOf(html, "select-mode").includes("disabled"), "mode select disabled"); assert.ok(tagOf(html, "select-mode").includes("disabled"), "mode select disabled");
assert.ok(html.includes("Light ships with the design wave")); assert.ok(html.includes("Coming soon"));
// The whole row dims, graph-view style (the control never adds its own). // The whole row dims, graph-view style (the control never adds its own).
assert.ok(tagOf(html, "row-mode").includes("opacity-40"), "mode row dims whole"); assert.ok(tagOf(html, "row-mode").includes("opacity-40"), "mode row dims whole");
}); });
@@ -238,7 +238,7 @@ describe("KeyboardSection (thin SSR)", () => {
it("displayOnly actions list but never offer Rebind", () => { it("displayOnly actions list but never offer Rebind", () => {
const html = renderToStaticMarkup(<KeyboardSection />); const html = renderToStaticMarkup(<KeyboardSection />);
assert.ok(html.includes("Owned by a surface — not rebindable")); assert.ok(html.includes("Fixed — part of this screen"));
assert.ok(!html.includes(`data-testid="kbd-rebind-question-next-row"`), "surface keys stay put"); assert.ok(!html.includes(`data-testid="kbd-rebind-question-next-row"`), "surface keys stay put");
assert.ok(!html.includes(`data-testid="kbd-rebind-model-menu-pick"`)); assert.ok(!html.includes(`data-testid="kbd-rebind-model-menu-pick"`));
}); });
+61
View File
@@ -0,0 +1,61 @@
/**
* Tooltip-wave mechanism pins (owner, 2026-09-25): the composition rule —
* icon/pill controls ride the styled Radix tooltip via Hint, the shortcut
* appended as an em-dash suffix off the LIVE registry (a rebind changes
* the suffix); truncation-reveals keep native title=; the copy carries no
* internal vocabulary. Node has no navigator → IS_MAC is false → the mod
* glyph renders "Ctrl+" here; the composition logic is the pin, not the
* platform glyph. (Node 26 ships navigator.platform — on this Mac the real
* ⌘/⇧ glyphs render, so they're pinned as-is.)
*/
import React from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Hint, hintText } from "../components/ui/hint.tsx";
import { AUTO_UPDATE_HINT } from "../components/settings/settings-dialog.tsx";
describe("hintText — the em-dash composition", () => {
it("a registry action appends the LIVE binding (override-aware source)", () => {
assert.equal(hintText("Dictate", "toggle-dictation"), "Dictate — ⌘D");
assert.equal(hintText("Settings", "open-settings"), "Settings — ⌘,");
assert.equal(hintText("Search sessions", "sessions-search"), "Search sessions — ⌘⇧F");
});
it("surface-owned keys ride the binding prop (no registry entry)", () => {
assert.equal(hintText("Close", undefined, "escape"), "Close — Esc");
assert.equal(hintText("Send", undefined, "enter"), "Send — ↵");
});
it("no shortcut = the label alone, no dangling dash", () => {
assert.equal(hintText("Home"), "Home");
assert.equal(hintText("Stop dictation (Esc)"), "Stop dictation (Esc)");
});
});
describe("spot-pins: the dev-speak cull (owner copy review at merge)", () => {
it("the auto-update hint is honest user language — no stream jargon", () => {
assert.equal(AUTO_UPDATE_HINT, "Automatic updates arrive in an upcoming release");
assert.ok(!AUTO_UPDATE_HINT.includes("wave"), "no stream vocabulary");
});
});
describe("Hint — the mechanism (SSR: the tooltip itself stays closed)", () => {
it("asChild: no wrapper DOM — the control keeps its own testid and classes", () => {
const html = renderToStaticMarkup(
createElement(Hint, {
label: "Dictate",
action: "toggle-dictation",
children: createElement("button", { "data-testid": "dictation-toggle", className: "the-quiet-chrome" }, "Dictate"),
}),
);
assert.match(html, /^<button/, "the trigger IS the button — nothing wraps it");
assert.ok(html.includes('data-testid="dictation-toggle"'));
assert.ok(html.includes("the-quiet-chrome"));
assert.ok(!html.includes("<span"), "no portal content renders while closed");
});
});