diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 55674e9..6461401 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -39,6 +39,15 @@ export function requestOpenSpawn(): void { document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT)); } +/** Ask the shell to open the command bar with an empty query. Dispatched by + * the sidebar's search field, which is the touch entry point to the bar + * (#157, docs/COMMAND-BAR.md "Touch entry"): there is no `ctrl-k` on a phone. + * Handled in main.ts, which owns the session snapshot the bar needs. */ +export const OPEN_COMMAND_BAR_EVENT = "catalog-open-command-bar"; +export function requestOpenCommandBar(): void { + document.dispatchEvent(new CustomEvent(OPEN_COMMAND_BAR_EVENT)); +} + /** Ask the shell to hand the keyboard back to the focused pane's terminal — * what Tab in the sidebar list does. Handled in main.ts, which owns the * connection pool. */ @@ -361,16 +370,12 @@ function beginRename(row: HTMLElement, s: Session): void { input.select(); } -function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement { +// The tree has one empty state: no sessions at all. The old "No matches" one +// belonged to the inline substring filter, which the command bar replaced +// (#157) — the bar's own no-results state took over with it. +function renderEmpty(onSpawn: () => void): HTMLElement { const el = document.createElement("div"); el.className = "empty"; - if (filtering) { - el.innerHTML = ` - -
No matches
-

No sessions match your search. Clear it to see everything.

`; - return el; - } el.innerHTML = `
No sessions yet
@@ -441,12 +446,16 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S // renders with absolute paths until it lands. let homeDir: string | undefined; + // The field never holds a query: it is the touch entry point to the command + // bar (#157), so it is `readonly` — a tap opens the bar instead of raising an + // on-screen keyboard over a field nothing is typed into. const searchHeader = document.createElement("header"); searchHeader.className = "sidebar-search"; searchHeader.innerHTML = ` { - filter = search.value; - refresh(); + const openBar = () => { + search.value = ""; + search.blur(); + requestOpenCommandBar(); + }; + search.addEventListener("focus", openBar); + // A tap can land without a focus event (iOS Safari on a blurred field), so + // the pointer path opens the bar too; the blur above keeps the two from + // stacking two overlays. + search.addEventListener("pointerdown", (e) => { + e.preventDefault(); + openBar(); }); let highlights: SidebarHighlights = { open: new Set(), focused: null }; @@ -716,8 +737,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S if (renamingId !== null) return; const state = store.snapshot(); const sessions = [...state.sessions.values()]; - const tree = buildTree(sessions, homeDir, filter); - const signature = sessions.length === 0 ? `EMPTY:${filter.trim()}` : treeSignature(tree); + const tree = buildTree(sessions, homeDir); + const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree); if (signature === renderedSignature) { for (const row of treeHost.querySelectorAll(".tree-leaf")) { const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined; @@ -728,9 +749,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S renderedSignature = signature; treeHost.replaceChildren(); if (sessions.length === 0) { - treeHost.appendChild( - renderEmpty(filter.trim().length > 0, () => requestOpenSpawn()), - ); + treeHost.appendChild(renderEmpty(() => requestOpenSpawn())); return; } if (tree.kind === "folder") { diff --git a/web/src/main.ts b/web/src/main.ts index 0ffc989..dafd9dd 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -36,6 +36,7 @@ import { createPreviewSchedule } from "./previewSchedule.js"; import { FOCUS_PANE_EVENT, FOCUS_SIDEBAR_EVENT, + OPEN_COMMAND_BAR_EVENT, OPEN_SPAWN_EVENT, SESSION_SELECT_EVENT, SPAWN_SIMILAR_EVENT, @@ -467,17 +468,19 @@ const focusKeys = new CommandSet( // Ctrl+K opens the command bar (docs/COMMAND-BAR.md). It is a registry command // like any other, so the `?` overlay lists it without a second list. -const barKeys = new CommandSet( - defineCommands({ - "bar.open": () => { - // Opening while a session is previewed discards the preview first, like - // any other real change (docs/PREVIEW.md): the bar is not previewed into. - schedulePreview.cancel(); - mv?.previewSession(null); - openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir }); - }, - }), -); +const openBar = () => { + // Opening while a session is previewed discards the preview first, like + // any other real change (docs/PREVIEW.md): the bar is not previewed into. + schedulePreview.cancel(); + mv?.previewSession(null); + openCommandBar({ sessions: [...store.snapshot().sessions.values()], homeDir }); +}; + +const barKeys = new CommandSet(defineCommands({ "bar.open": () => openBar() })); + +// The sidebar's search field is the touch entry point (#157): no `ctrl-k` on a +// phone, so the field the operator already reaches for opens the same bar. +document.addEventListener(OPEN_COMMAND_BAR_EVENT, () => openBar()); // Capture phase: an attached xterm swallows every keystroke it is given, so the // bar's binding has to be seen ahead of the terminal (docs/COMMAND-BAR.md). diff --git a/web/src/sidebarSearch.test.ts b/web/src/sidebarSearch.test.ts new file mode 100644 index 0000000..e4aa7ad --- /dev/null +++ b/web/src/sidebarSearch.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment happy-dom +// +// The sidebar's search field is the touch entry point to the command bar +// (#157, docs/COMMAND-BAR.md "Touch entry"): tapping or focusing it asks the +// shell to open the bar with an empty query, instead of filtering the tree. +import { beforeAll, describe, expect, it } from "vitest"; + +import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js"; +import { sessionBarCandidates } from "./commandBar.js"; +import { matchAndRank } from "./matcher.js"; +import type { CatalogListener, CatalogStore } from "./events.js"; +import type { Session } from "./types.js"; + +beforeAll(() => { + Object.defineProperty(window, "localStorage", { + value: { getItem: () => null, setItem: () => {} }, + }); + window.fetch = () => Promise.reject(new Error("offline")); +}); + +function session(id: string, cwd: string): Session { + return { + id, + name: id, + cwd, + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-08-24T10:00:00Z", + } as Session; +} + +function storeWith(sessions: Session[]): CatalogStore { + const state = { sessions: new Map(sessions.map((s) => [s.id, s])) }; + return { + snapshot: () => state, + subscribe: (fn: CatalogListener) => { + fn(state); + return () => {}; + }, + } as unknown as CatalogStore; +} + +function mount(sessions: Session[] = [session("alpha", "/home/n/alpha")]) { + const handle = renderSidebar(storeWith(sessions)); + document.body.appendChild(handle.el); + const search = handle.el.querySelector(".search")!; + const opens: number[] = []; + const listener = () => opens.push(1); + document.addEventListener(OPEN_COMMAND_BAR_EVENT, listener); + return { + handle, + search, + opens, + dispose: () => { + document.removeEventListener(OPEN_COMMAND_BAR_EVENT, listener); + handle.dispose(); + handle.el.remove(); + }, + }; +} + +describe("sidebar search field opens the command bar (#157)", () => { + it("asks the shell to open the bar when the field is focused", () => { + const t = mount(); + t.search.dispatchEvent(new FocusEvent("focus")); + expect(t.opens).toHaveLength(1); + t.dispose(); + }); + + it("opens on a tap, where a focus event may never arrive", () => { + const t = mount(); + t.search.dispatchEvent(new Event("pointerdown", { bubbles: true })); + expect(t.opens).toHaveLength(1); + t.dispose(); + }); + + it("does not trap the keyboard in the field: it gives focus up on open", () => { + const t = mount(); + t.search.focus(); + expect(document.activeElement).not.toBe(t.search); + t.dispose(); + }); + + it("opens the bar with an empty query — the field never holds one", () => { + const t = mount(); + t.search.focus(); + expect(t.search.value).toBe(""); + t.dispose(); + }); + + it("is readonly: no on-screen keyboard over a field nothing is typed into", () => { + const t = mount(); + expect(t.search.readOnly).toBe(true); + t.dispose(); + }); + + it("no longer filters the tree in place", () => { + const t = mount([session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")]); + t.search.value = "alpha"; + t.search.dispatchEvent(new Event("input")); + expect(t.handle.el.querySelectorAll(".tree-leaf")).toHaveLength(2); + t.dispose(); + }); + + it("has no 'No matches' state left: an empty catalog is empty, not filtered", () => { + const t = mount([]); + expect(t.handle.el.querySelector(".empty-title")?.textContent).toBe("No sessions yet"); + t.dispose(); + }); +}); + +// The inline filter matched a session on its name and its directory, case- and +// whitespace-insensitively. That behaviour did not go away with the field — it +// moved into the bar, which ranks the same two fields (docs/COMMAND-BAR.md). +describe("what the retired inline filter did, the bar now does", () => { + it("matches a session on both its name and its directory", () => { + const sessions = [session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")]; + const candidates = sessionBarCandidates(sessions, "/home/n"); + + const byName = matchAndRank("alpha", candidates).map((r) => r.candidate.session.id); + expect(byName).toEqual(["alpha"]); + + const byDir = matchAndRank("beta", candidates).map((r) => r.candidate.session.id); + expect(byDir).toEqual(["beta"]); + }); + + it("ignores case and surrounding whitespace, and shows nothing on no match", () => { + const candidates = sessionBarCandidates([session("alpha", "/home/n/Alpha")], "/home/n"); + + expect(matchAndRank(" ALPHA ", candidates)).toHaveLength(1); + expect(matchAndRank("zzz", candidates)).toHaveLength(0); + }); +}); diff --git a/web/src/styles.css b/web/src/styles.css index 15d583f..b5cd263 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1560,7 +1560,10 @@ input.leaf-rename:focus { } /* Phone: the bottom-sheet treatment the spawn modal already uses. */ -@media (max-width: 600px) { +/* Bottom sheet at phone width, the same treatment (and the same 599px break) + the spawn modal uses: the bar is reached by thumb here, through the sidebar's + search field (#157), so it rides the bottom of the screen. */ +@media (max-width: 599px) { .cmdbar-overlay { grid-template-rows: 1fr auto; padding: 0; @@ -1569,9 +1572,21 @@ input.leaf-rename:focus { .cmdbar-panel { grid-row: 2; width: 100%; - max-height: 70vh; + max-height: 70dvh; border-radius: var(--r-3) var(--r-3) 0 0; border-bottom: 0; + padding-bottom: env(safe-area-inset-bottom, 0px); + } + + /* Thumb-sized rows and a field that clears the on-screen keyboard's + zoom-on-focus threshold (16px). */ + .cmdbar-input { + font-size: var(--fs-17); + min-height: 44px; + } + + .cmdbar-row { + min-height: 44px; } } diff --git a/web/src/tree.test.ts b/web/src/tree.test.ts index e3f9642..0c6153f 100644 --- a/web/src/tree.test.ts +++ b/web/src/tree.test.ts @@ -26,7 +26,6 @@ describe("buildTree", () => { const root = buildTree( [session("a", "/home/n/work"), session("b", "/home/n/work")], "/home/n", - "", ) as TreeFolder; expect(names(root)).toEqual(["~/work"]); @@ -35,7 +34,7 @@ describe("buildTree", () => { }); it("collapses a linear chain into one folder", () => { - const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n", "") as TreeFolder; + const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n") as TreeFolder; expect(names(root)).toEqual(["~/git/naps62/maestro"]); }); @@ -43,7 +42,6 @@ describe("buildTree", () => { const root = buildTree( [session("a", "/home/n/subvisual/bullish"), session("b", "/home/n/tea/maestro")], "/home/n", - "", ) as TreeFolder; expect(names(root).sort()).toEqual(["~/subvisual/bullish", "~/tea/maestro"]); @@ -53,7 +51,6 @@ describe("buildTree", () => { const root = buildTree( [session("a", "/home/n/git/one"), session("b", "/home/n/git/two")], "/home/n", - "", ) as TreeFolder; expect(names(root)).toEqual(["~/git"]); @@ -65,30 +62,11 @@ describe("buildTree", () => { const root = buildTree( [session("loose", ""), session("nested", "/home/n/work")], "/home/n", - "", ) as TreeFolder; expect(root.children[0].kind).toBe("folder"); expect(root.children[1].kind).toBe("leaf"); }); - - it("filters on both session name and directory", () => { - const sessions = [session("a", "/home/n/alpha", "claude"), session("b", "/home/n/beta", "bash")]; - - const byDir = buildTree(sessions, "/home/n", "alpha") as TreeFolder; - expect(names(byDir)).toEqual(["~/alpha"]); - - const byCommand = buildTree(sessions, "/home/n", "claude") as TreeFolder; - expect(names(byCommand)).toEqual(["~/alpha"]); - - const noMatch = buildTree(sessions, "/home/n", "zzz") as TreeFolder; - expect(noMatch.children).toEqual([]); - }); - - it("ignores case and surrounding whitespace in the filter", () => { - const root = buildTree([session("a", "/home/n/Alpha")], "/home/n", " ALPHA ") as TreeFolder; - expect(root.children).toHaveLength(1); - }); }); describe("mergeLinear", () => { @@ -127,7 +105,6 @@ describe("worktree sessions group under their repository (#99)", () => { ), ], "/home/n", - "", ) as TreeFolder; expect(topFolders(root)).toEqual(["~/tea/agent-skills"]); @@ -147,7 +124,6 @@ describe("worktree sessions group under their repository (#99)", () => { ), ], "/home/n", - "", ) as TreeFolder; expect(topFolders(root)).toEqual(["~/tea/agent-skills"]); @@ -157,7 +133,7 @@ describe("worktree sessions group under their repository (#99)", () => { }); it("leaves a session without a worktree where it is", () => { - const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder; + const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n") as TreeFolder; expect(topFolders(root)).toEqual(["~/tea/arr"]); }); }); diff --git a/web/src/tree.ts b/web/src/tree.ts index b403d94..0842849 100644 --- a/web/src/tree.ts +++ b/web/src/tree.ts @@ -25,19 +25,14 @@ export function compareNodes(a: TreeNode, b: TreeNode): number { return an.localeCompare(bn); } -export function buildTree(sessions: Session[], home: string | undefined, filter: string): TreeNode { +export function buildTree(sessions: Session[], home: string | undefined): TreeNode { const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] }; - const lc = filter.trim().toLowerCase(); for (const s of sessions) { // Grouped by repository, so a worktree session lands under its repo // rather than in a folder of its own (#99). const cwd = sessionTreePath(s); const dir = cwd ? abbreviateCwd(cwd, home) : ""; - if (lc) { - const haystack = `${sessionName(s)} ${dir}`.toLowerCase(); - if (!haystack.includes(lc)) continue; - } const parts = dir ? dir.split("/").filter(Boolean) : []; let cursor = root; for (const part of parts) {