spotlight: ctrl-space, type a few letters, go

The dock is full and cannot grow. Once navigation lives here it is free to be a short list of what you
actually use rather than a complete one — which is the other half of this and is not done yet.

It INDEXES THE DOCK rather than keeping a list of its own. The dock is already the composed,
permission-filtered answer to "what can this account open here": the shell's screens, plus whatever
installed plugins contribute, minus anything canVisit refuses. A second list would be a second answer
to one question, and it would be wrong the day somebody installs a plugin. So a new plugin appears in
Spotlight for the reason it appears in the dock — because it is installed — and nothing here is edited.

Settings sections are added, because they are what people hunt for and the dock deliberately does not
carry them. Those ARE hand-listed, which is a real cost written down in the file: a section added to
IntegrationsSettings will not appear until someone adds it here. The alternative couples five settings
screens to the shell to save five lines.

Matching is subsequence, not substring, and lives in its own file so it can be tested with strings:
"chq" finds Command HQ, "apik" finds API keys, "fi" gives Files rather than Profile. The weights encode
what "the right result" means — exact label, then prefix, then word boundaries, then adjacency, with
shortness breaking ties so "Chat" beats "Chat history". Labels are worth about three times hints, and
paths almost nothing, so a path fragment finds an item without ever outranking a real label. Nine tests
cover the ORDER rather than the matching, because returning the right set is easy and returning it with
the right thing first is the product.

ctrl-space, not alt-space: the more macOS-shaped chord is already load-bearing for the owner. Not
cmd-k either — that belongs to the browser on some platforms and to text fields on others.

Mounted beside the header and the dock rather than inside the content region, so it still gets you off
a screen that is maximised, erroring or mid-navigation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 03:48:46 +00:00
co-authored by Claude Opus 5
parent ee7bc4920c
commit 81f32a1faa
5 changed files with 434 additions and 0 deletions
@@ -7,6 +7,7 @@ import { ScreenErrorFallback } from './ScreenErrorFallback';
import { Background } from './Background';
import { Header } from './Header';
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, defaultDockPaths } from './Dock';
import { Spotlight } from './Spotlight/Spotlight';
import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle';
import { RouteGate } from './RouteGate';
@@ -47,6 +48,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
<Header dockItems={visibleItems} hidden={panelFullscreen} />
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
<Spotlight />
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { Search } from 'lucide-react';
import { rank } from './match';
import { useSpotlightItems, type SpotlightItem } from './useSpotlightItems';
// Spotlight: type a few letters, go somewhere.
//
// The dock is full and cannot grow, which is what this is for — once navigation lives here, the dock is
// free to be a short list of what you actually use rather than a complete one.
//
// ── Keyboard first, and the trigger ──
//
// `ctrl-space`. `alt-space` is the more macOS-shaped chord and is already load-bearing for the owner
// elsewhere, so it is deliberately not taken. `cmd-k` is not used either: it belongs to the browser's own
// surface on some platforms and to text fields on others.
//
// Escape closes, arrows move, enter goes. The mouse works, but nothing here is designed around it.
const MAX_RESULTS = 8;
/** Label with the matched characters picked out, so it is obvious WHY a result is here. */
const Highlighted = ({ label, hits }: { label: string; hits: number[] }) => {
if (!hits.length) return <>{label}</>;
const set = new Set(hits);
return (
<>
{label.split('').map((char, i) =>
set.has(i) ? (
<span key={i} className="text-duck-teal">
{char}
</span>
) : (
<span key={i}>{char}</span>
),
)}
</>
);
};
const Row = ({
item,
hits,
active,
onPick,
}: {
item: SpotlightItem;
hits: number[];
active: boolean;
onPick: () => void;
}) => {
const Icon = item.icon;
return (
<button
type="button"
// `onMouseDown`, not `onClick`: the input holds focus and a click would blur it first, closing the
// overlay on the blur handler before the navigation ever ran.
onMouseDown={(ev) => {
ev.preventDefault();
onPick();
}}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors ${
active ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md"
style={item.color ? { backgroundColor: `${item.color}22`, color: item.color } : undefined}
>
{item.image ? (
<img src={item.image} alt="" className="h-5 w-5 object-contain" />
) : Icon ? (
<Icon className="h-4 w-4" />
) : (
<Search className="h-4 w-4" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">
<Highlighted label={item.label} hits={hits} />
</span>
{item.hint ? <span className="block truncate text-xs text-muted-foreground">{item.hint}</span> : null}
</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/60">{item.to}</span>
</button>
);
};
export const Spotlight = () => {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);
const navigate = useNavigate();
const items = useSpotlightItems();
const results = useMemo(() => rank(items, query).slice(0, MAX_RESULTS), [items, query]);
const close = useCallback(() => {
setOpen(false);
setQuery('');
setActive(0);
}, []);
const pick = useCallback(
(item: SpotlightItem | undefined) => {
if (!item) return;
close();
navigate(item.to);
},
[close, navigate],
);
// The trigger. Bound to the window rather than to a container, because it has to work while focus is
// anywhere — including inside a panel that has its own key handling.
useEffect(() => {
const onKey = (ev: KeyboardEvent) => {
if (ev.ctrlKey && ev.code === 'Space') {
ev.preventDefault();
setOpen((wasOpen) => !wasOpen);
return;
}
if (ev.key === 'Escape' && open) close();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, close]);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
// Reset the cursor whenever the result set changes, so it never points past the end.
useEffect(() => setActive(0), [query]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-[12vh] backdrop-blur-sm"
onMouseDown={close}
>
<div
className="w-[min(92vw,560px)] overflow-hidden rounded-2xl border border-border bg-background/95 shadow-2xl"
// The backdrop closes on mousedown; this stops a click INSIDE the panel counting as one.
onMouseDown={(ev) => ev.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={query}
onChange={(ev) => setQuery(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'ArrowDown') {
ev.preventDefault();
setActive((i) => Math.min(i + 1, results.length - 1));
} else if (ev.key === 'ArrowUp') {
ev.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (ev.key === 'Enter') {
ev.preventDefault();
pick(results[active]?.item);
}
}}
placeholder="Go to…"
className="w-full bg-transparent py-3.5 text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-[52vh] overflow-y-auto p-2">
{results.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
) : (
results.map((result, i) => (
<Row
key={result.item.id}
item={result.item}
hits={result.hits}
active={i === active}
onPick={() => pick(result.item)}
/>
))
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,67 @@
import { describe, expect, test } from 'bun:test';
import { rank } from './match';
import type { SpotlightItem } from './useSpotlightItems';
// The behaviours a launcher is judged on, which are all about ORDER rather than about matching. Anyone
// can return the right set; returning it with the right thing first is the whole product.
const item = (label: string, to: string, hint?: string): SpotlightItem => ({ id: to, label, to, hint });
const ITEMS: SpotlightItem[] = [
item('Home', '/'),
item('Files', '/files'),
item('Chat', '/chat'),
item('Command HQ', '/command-hq'),
item('Editor', '/code-editor'),
item('Profile', '/settings/profile', 'avatar, name, email'),
item('API keys', '/settings/profile/api-keys', 'per-app keys'),
item('App store', '/app-store'),
item('Dashboards', '/dashboards'),
];
const top = (query: string, n = 1) =>
rank(ITEMS, query)
.slice(0, n)
.map((s) => s.item.label);
describe('spotlight ranking', () => {
test('an exact label wins', () => {
expect(top('chat')).toEqual(['Chat']);
});
test('a prefix beats a scattered match — "fi" is Files, not Profile', () => {
expect(top('fi')).toEqual(['Files']);
});
test('initials find a two-word label — "chq" is Command HQ', () => {
expect(top('chq')).toEqual(['Command HQ']);
});
test('a subsequence works, so you can type the shape of a word', () => {
expect(top('apik')).toEqual(['API keys']);
});
test('a hint matches, but never outranks a label', () => {
// "keys" is API keys' own label AND its hint. It must not be beaten by anything mentioning keys.
expect(top('keys')).toEqual(['API keys']);
});
test('a path fragment finds the item without outranking labels', () => {
expect(rank(ITEMS, 'settings').map((s) => s.item.label)).toContain('Profile');
// "app" is a prefix of "App store" and appears in API keys' hint ("per-app keys"). Label wins.
expect(top('app')).toEqual(['App store']);
});
test('nothing matches means nothing returned, not everything', () => {
expect(rank(ITEMS, 'zzzzz')).toHaveLength(0);
});
test('an empty query keeps the given order rather than sorting it', () => {
expect(rank(ITEMS, '').map((s) => s.item.label)).toEqual(ITEMS.map((i) => i.label));
});
test('shorter labels break ties — "Chat" over "Chat history"', () => {
const withLonger = [...ITEMS, item('Chat history', '/chat-history')];
expect(rank(withLonger, 'chat')[0]!.item.label).toBe('Chat');
});
});
@@ -0,0 +1,93 @@
import type { SpotlightItem } from './useSpotlightItems';
// Ranking, kept apart from React so it can be tested with strings.
//
// Subsequence matching, not substring: "cmdhq" should find "Command HQ" and "apik" should find
// "API keys". Anyone who has used a launcher expects to type the shape of a word rather than a prefix
// of it, and a substring match makes them type more to get less.
export type Scored = { item: SpotlightItem; score: number; hits: number[] };
/**
* Where each character of `query` lands in `text`, or null if it does not fit.
*
* Greedy left-to-right, which is what makes it cheap and predictable. It can miss a better alignment —
* "ao" against "app store" takes the first `o` in the first word rather than the one in "store" — and
* that costs a slightly worse score, never a wrong answer, because the item still matches.
*/
function subsequence(text: string, query: string): number[] | null {
const hits: number[] = [];
let at = 0;
for (const char of query) {
const found = text.indexOf(char, at);
if (found === -1) return null;
hits.push(found);
at = found + 1;
}
return hits;
}
/** Start of the string, or the character after a space, dash or slash. */
const isBoundary = (text: string, i: number): boolean => i === 0 || /[\s\-/]/.test(text[i - 1]!);
/**
* Higher is better. The weights encode what a person means by "the right result":
*
* - **an exact label** is what they typed, so nothing beats it
* - **a prefix** beats a scattered match — typing "fi" should rank Files above Profile
* - **word boundaries** are why "chq" finds Command HQ at all, and should rank it above an item where
* those letters happen to fall mid-word
* - **adjacency** rewards a run of letters, which is what a substring match would have found
* - **shortness** breaks ties toward the more specific label: "Chat" over "Chat history"
*
* The label is worth roughly three times the hint, so typing a word that IS a label beats an item that
* merely mentions it in passing.
*/
function scoreOne(text: string, query: string, weight: number): { score: number; hits: number[] } | null {
const hits = subsequence(text, query);
if (!hits) return null;
let score = 0;
if (text === query) score += 1000;
else if (text.startsWith(query)) score += 400;
for (let i = 0; i < hits.length; i++) {
const at = hits[i]!;
if (isBoundary(text, at)) score += 30;
if (i > 0 && at === hits[i - 1]! + 1) score += 15;
}
// Shorter labels win ties. Capped so a very long one is not driven negative.
score += Math.max(0, 40 - text.length);
return { score: score * weight, hits };
}
/**
* Rank items against a query.
*
* An empty query returns everything in its given order — the index is already in a sensible one (dock
* first, settings after), and imposing an alphabetical sort on an empty box would move Home away from
* the top for no reason.
*/
export function rank(items: SpotlightItem[], query: string): Scored[] {
const q = query.trim().toLowerCase();
if (!q) return items.map((item) => ({ item, score: 0, hits: [] }));
const scored: Scored[] = [];
for (const item of items) {
const label = scoreOne(item.label.toLowerCase(), q, 1);
const hint = item.hint ? scoreOne(item.hint.toLowerCase(), q, 0.3) : null;
// The PATH is matched too, unweighted almost to nothing: it is how "/settings/profile" finds Profile
// when somebody types a URL fragment, without letting a path ever outrank a real label.
const path = scoreOne(item.to.toLowerCase(), q, 0.15);
const best = [label, hint, path]
.filter((s): s is { score: number; hits: number[] } => !!s)
.sort((a, b) => b.score - a.score)[0];
if (!best) continue;
// `hits` is only meaningful for the label, since that is the only string rendered with highlighting.
scored.push({ item, score: best.score, hits: label?.hits ?? [] });
}
return scored.sort((a, b) => b.score - a.score || a.item.label.length - b.item.label.length);
}
@@ -0,0 +1,81 @@
import { useMemo } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Settings, User, Puzzle, Server, Cpu, Users } from 'lucide-react';
import { usePermissions } from 'hooks/usePermissions';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins } from '../Dock';
// What Spotlight can take you to.
//
// ── It indexes the DOCK, rather than a list of its own ──
//
// The dock is already the composed, permission-filtered answer to "what can this account open here":
// the shell's own screens, plus whatever installed plugins contribute, minus anything `canVisit` refuses.
// Building a second list would mean two answers to one question, and the second would be wrong the day
// somebody installs a plugin — which is the whole failure the dock's own comment warns about, arrived at
// from a third direction.
//
// So a new plugin appears in Spotlight for the same reason it appears in the dock: because it is
// installed. Nothing here is edited.
//
// Settings sections are added because they are the things people hunt for and the dock deliberately does
// not carry. They are static: a settings screen is shell, not a plugin.
export type SpotlightItem = {
/** Stable across renders — used as the React key and for the recency store. */
id: string;
label: string;
/** Where it goes. Matched against `canVisit` for anything not already filtered. */
to: string;
/** Shown small beside the label, so two "Profile"s are distinguishable. */
hint?: string;
icon?: LucideIcon;
/** Plugin artwork, when the tile has some. */
image?: string;
color?: string;
};
/**
* The settings sections, which the dock does not carry.
*
* Hand-listed rather than derived, and that is a real cost: a section added to `IntegrationsSettings`
* will not appear here until someone adds it. The alternative is exporting every settings screen's
* section array up into the shell, which couples five screens to this one to save five lines.
*
* Worth revisiting if it drifts. `hint` is what makes them findable — typing "keys" should reach the API
* keys screen, and the label alone does not say where it lives.
*/
const SETTINGS_ITEMS: SpotlightItem[] = [
{ id: 'set:profile', label: 'Profile', to: '/settings/profile', hint: 'avatar, name, email', icon: User },
{ id: 'set:password', label: 'Change password', to: '/settings/profile/change-password', icon: User },
{ id: 'set:api-keys', label: 'API keys', to: '/settings/profile/api-keys', hint: 'per-app keys', icon: User },
{ id: 'set:dock', label: 'Dock', to: '/settings/profile/dock', hint: 'choose and reorder', icon: User },
{ id: 'set:integrations', label: 'Integrations', to: '/settings/integrations', icon: Puzzle },
{ id: 'set:server', label: 'Server settings', to: '/settings/server', icon: Server },
{ id: 'set:system', label: 'System', to: '/settings/system', icon: Cpu },
{ id: 'set:users', label: 'Users', to: '/settings/users', hint: 'accounts and permissions', icon: Users },
{ id: 'set:ai', label: 'AI', to: '/settings/ai', hint: 'models and harnesses', icon: Settings },
];
/** Everything Spotlight can offer this account, in a stable order. */
export function useSpotlightItems(): SpotlightItem[] {
const { canVisit, plugins } = usePermissions();
return useMemo(() => {
const fromDock: SpotlightItem[] = [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)]
.filter((item) => canVisit(item.to))
.map((item) => ({
id: `nav:${item.to}`,
label: item.label,
to: item.to,
icon: item.icon,
image: item.image,
color: item.color,
}));
// Settings go through `canVisit` too. `usePermissions` fails OPEN by design — hiding a door is a
// courtesy and the 403 is the lock — so this cannot lock anyone out of their own settings.
const settings = SETTINGS_ITEMS.filter((item) => canVisit(item.to));
return [...fromDock, ...settings];
}, [canVisit, plugins]);
}