diff --git a/docs/features/global-search.mdx b/docs/features/global-search.mdx
index cbbda164..2cef5df9 100644
--- a/docs/features/global-search.mdx
+++ b/docs/features/global-search.mdx
@@ -37,7 +37,7 @@ Each stack row shows a status dot on the left (green for running, grey for exite
## Typing to filter
-Start typing and the results narrow in real time. Pages and nodes filter instantly; stack search debounces for about 250 ms before fanning out across the fleet, so a fast typist sees the Stacks group fill in a beat after the rest. Matching is substring-based, so you can type a fragment from anywhere in a stack's filename or a node's name. Examples:
+Start typing and the results narrow in real time. Pages and nodes filter instantly. Stack search debounces for about 250 ms before fanning out across the fleet when you begin a search; once the results are in, refining the query filters them instantly without querying the fleet again. Matching is substring-based, so you can type a fragment from anywhere in a stack's filename or a node's name. Examples:
- `fleet` jumps to the Fleet page.
- `local` selects the Local node and switches the active context to it.
@@ -57,6 +57,8 @@ The Stacks group caps at 50 rows. When a query matches more than that, a `Showin
While the fleet is responding, the empty area reads `Searching...` until the first batch of stack hits arrives. If no group has a hit at all, the empty area reads `No results.`
+If a node cannot be reached while the search is running, the palette still shows the stacks it could gather from the rest of the fleet and adds a `N nodes unreachable, stack results may be incomplete` line beneath the Stacks group. Hover the line to see which nodes were skipped and why. This tells you a match might exist on a host that did not answer, rather than leaving you to assume the stack does not exist.
+
## Keyboard navigation
Once the palette is open:
@@ -66,3 +68,23 @@ Once the palette is open:
- Esc closes the palette.
Offline nodes show as greyed-out and are not selectable.
+
+## Troubleshooting
+
+
+
+ Stack search needs permission to read stacks on each node. If your account lacks that access on a node, that node's stacks are skipped and it is reported in the `nodes unreachable` line beneath the group. Search also only covers online nodes; a node that is offline in your fleet list is never queried. Confirm the node is online and that your account can read its stacks, then run the search again.
+
+
+ The palette fans out to every online node when you start a search. If a node does not answer in time, returns an error, or drops mid-search, its stacks are left out and the count is added to the `N nodes unreachable, stack results may be incomplete` line. Hover that line to see which nodes were skipped and the reason. The stacks from every node that did answer still appear, so you are never left with a silently short list. Re-run the search once the node is reachable to fold its stacks back in.
+
+
+ The fleet-wide stack list is gathered once when you begin a search and reused while you keep typing, so a stack created on a remote node after you started typing will not appear yet. Clear the query and search again to pull a fresh list from the fleet.
+
+
+ The dot reflects the stack's run state: green for running, grey for exited, faded grey for unknown. Unknown means the node listed the stack but its status could not be read at that moment, usually a transient hiccup talking to that host's Docker engine. The stack is still selectable; open it to see its real state in the editor.
+
+
+ The shortcut is handled at the window level, so it works from any screen, but a focused element that captures the keystroke first (for example a terminal in the host console) can intercept it. Click outside that element, or use the search icon in the top bar to open the palette instead.
+
+
diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts
new file mode 100644
index 00000000..9c559213
--- /dev/null
+++ b/e2e/command-palette.spec.ts
@@ -0,0 +1,93 @@
+/**
+ * Global search command palette E2E - the keyboard-driven navigation journey.
+ *
+ * The cross-node failure modes (a remote node returning 502/500 or throwing,
+ * which drives the "N nodes unreachable" affordance) are covered
+ * deterministically by the hook unit tests in
+ * frontend/src/hooks/__tests__/useCrossNodeStackSearch.test.tsx, since they
+ * cannot be triggered reliably from a single-node E2E environment.
+ */
+import { test, expect, type Page } from '@playwright/test';
+import { loginAs, waitForStacksLoaded } from './helpers';
+
+const PALETTE_INPUT = 'input[placeholder="Search the app..."]';
+const PALETTE_STACK = 'e2e-palette-stack';
+
+async function createStackViaUi(page: Page, name: string) {
+ await page.evaluate(async (n) => {
+ await fetch(`/api/stacks/${n}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
+ }, name);
+ await page.reload();
+ await loginAs(page);
+ await waitForStacksLoaded(page);
+
+ await page.getByRole('button', { name: 'Create Stack' }).click();
+ await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
+ await page.locator('#create-stack-name').fill(name);
+ await page.locator('[role="dialog"]').getByRole('button', { name: 'Create' }).click();
+ await expect(page.getByRole('dialog')).toBeHidden({ timeout: 8_000 });
+}
+
+test.describe('Global search command palette', () => {
+ test.beforeEach(async ({ page }) => {
+ await loginAs(page);
+ await waitForStacksLoaded(page);
+ });
+
+ test('opens with Ctrl+K and closes with Esc', async ({ page }) => {
+ await page.keyboard.press('Control+k');
+ await expect(page.locator(PALETTE_INPUT)).toBeVisible({ timeout: 5_000 });
+ await expect(page.getByRole('dialog').getByText('Pages', { exact: true })).toBeVisible();
+
+ await page.keyboard.press('Escape');
+ await expect(page.locator(PALETTE_INPUT)).toBeHidden({ timeout: 5_000 });
+ });
+
+ test('opens from the top-bar search trigger', async ({ page }) => {
+ await page.getByRole('button', { name: 'Open search (Ctrl+K)' }).click();
+ await expect(page.locator(PALETTE_INPUT)).toBeVisible({ timeout: 5_000 });
+ await page.keyboard.press('Escape');
+ await expect(page.locator(PALETTE_INPUT)).toBeHidden({ timeout: 5_000 });
+ });
+
+ test('filters pages by substring and navigates on select', async ({ page }) => {
+ await page.keyboard.press('Control+k');
+ const input = page.locator(PALETTE_INPUT);
+ await expect(input).toBeVisible({ timeout: 5_000 });
+
+ await input.fill('fleet');
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByText('Fleet', { exact: true })).toBeVisible();
+ // A non-matching page is hidden because the palette owns matching.
+ await expect(dialog.getByText('Home', { exact: true })).toBeHidden();
+
+ await dialog.getByText('Fleet', { exact: true }).click();
+ await expect(input).toBeHidden({ timeout: 5_000 });
+ });
+
+ test('shows "No results." for a query that matches nothing', async ({ page }) => {
+ await page.keyboard.press('Control+k');
+ const input = page.locator(PALETTE_INPUT);
+ await expect(input).toBeVisible({ timeout: 5_000 });
+
+ await input.fill('zzzznomatchzzzz');
+ await expect(page.getByRole('dialog').getByText('No results.')).toBeVisible({ timeout: 8_000 });
+ });
+
+ test('finds a stack by filename and surfaces it in the Stacks group', async ({ page }) => {
+ await createStackViaUi(page, PALETTE_STACK);
+
+ await page.keyboard.press('Control+k');
+ const input = page.locator(PALETTE_INPUT);
+ await expect(input).toBeVisible({ timeout: 5_000 });
+
+ await input.fill('e2e-palette');
+ const dialog = page.getByRole('dialog');
+ await expect(dialog.getByText('Stacks', { exact: true })).toBeVisible({ timeout: 8_000 });
+ await expect(dialog.getByText(new RegExp(PALETTE_STACK))).toBeVisible({ timeout: 8_000 });
+
+ // Selecting the stack closes the palette and opens it in the editor.
+ await dialog.getByText(new RegExp(PALETTE_STACK)).first().click();
+ await expect(input).toBeHidden({ timeout: 8_000 });
+ });
+});
diff --git a/frontend/src/components/GlobalCommandPalette.tsx b/frontend/src/components/GlobalCommandPalette.tsx
index a3ce7c70..8bda9cfc 100644
--- a/frontend/src/components/GlobalCommandPalette.tsx
+++ b/frontend/src/components/GlobalCommandPalette.tsx
@@ -7,11 +7,10 @@ import {
useState,
type ReactNode,
} from 'react';
-import { Search } from 'lucide-react';
+import { Search, AlertCircle } from 'lucide-react';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import {
CommandDialog,
- CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
@@ -103,7 +102,7 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl
const { nodes, activeNode, setActiveNode } = useNodes();
const [query, setQuery] = useState('');
- const { hits: remoteHits, loading: stacksLoading } = useCrossNodeStackSearch({
+ const { hits: remoteHits, failedNodes, loading: stacksLoading } = useCrossNodeStackSearch({
query,
enabled: open,
});
@@ -131,14 +130,29 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl
onSelectStack(node, hit.file);
}, [handleOpenChange, nodes, onSelectStack]);
- const visibleNodes = useMemo(() => {
- const q = query.trim().toLowerCase();
- if (!q) return nodes;
- return nodes.filter(n => n.name.toLowerCase().includes(q));
- }, [nodes, query]);
+ // cmdk's built-in filter is disabled (shouldFilter={false}) so the palette
+ // owns matching for every group. This keeps stack order deterministic (node
+ // order, then the 50-row cap) instead of being re-sorted by cmdk's scorer,
+ // and makes matching a plain case-insensitive substring across all groups.
+ const q = query.trim().toLowerCase();
+
+ const visiblePages = useMemo(
+ () => (q ? navItems.filter(i => i.label.toLowerCase().includes(q) || i.value.toLowerCase().includes(q)) : navItems),
+ [navItems, q],
+ );
+
+ const visibleNodes = useMemo(
+ () => (q ? nodes.filter(n => n.name.toLowerCase().includes(q)) : nodes),
+ [nodes, q],
+ );
+
+ const hasResults = visiblePages.length > 0 || visibleNodes.length > 0 || stackHits.length > 0;
+ // Suppress the empty state when a node failed (unless still loading): the
+ // "N nodes unreachable" line below already explains why results are missing.
+ const showEmptyState = !hasResults && (stacksLoading || failedNodes.length === 0);
return (
-
+ SearchJump to a page, node, or stack
@@ -149,24 +163,28 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl
onValueChange={setQuery}
/>
-
-
- {stacksLoading ? 'Searching...' : 'No results.'}
-
-
+ {showEmptyState && (
+