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) {