There is no ctrl-k on a phone and the web UI is mobile-first, so the field the operator already reaches for becomes the touch entry point: focusing or tapping it dispatches OPEN_COMMAND_BAR_EVENT and the shell opens the bar with an empty query. The field is readonly and blurs itself first, so no keyboard rises over it and the bar can be closed without reopening it. This replaces the inline substring filter — one search affordance in the header, not two. buildTree loses its filter argument, and the tree's "No matches" empty state goes with it; the bar's own no-results state took over. The filter's name-and-directory matching is now covered against the bar. The bar's phone-width sheet moves to the spawn modal's 599px break, with thumb-sized rows and a field that clears the zoom-on-focus threshold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
140 lines
4.3 KiB
TypeScript
140 lines
4.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { buildTree, mergeLinear, type TreeFolder, type TreeNode } from "./tree.js";
|
|
import type { Session } from "./types.js";
|
|
|
|
function session(id: string, cwd: string, command = "bash"): Session {
|
|
return {
|
|
id,
|
|
cwd,
|
|
command,
|
|
env: {},
|
|
metadata: {},
|
|
status: "Running",
|
|
created_at: "2026-08-22T00:00:00Z",
|
|
} as Session;
|
|
}
|
|
|
|
/** Folder names at one level, in render order. */
|
|
function names(node: TreeNode): string[] {
|
|
if (node.kind === "leaf") return [];
|
|
return node.children.map((c) => (c.kind === "folder" ? c.name : "leaf"));
|
|
}
|
|
|
|
describe("buildTree", () => {
|
|
it("groups sessions under their directory", () => {
|
|
const root = buildTree(
|
|
[session("a", "/home/n/work"), session("b", "/home/n/work")],
|
|
"/home/n",
|
|
) as TreeFolder;
|
|
|
|
expect(names(root)).toEqual(["~/work"]);
|
|
const folder = root.children[0] as TreeFolder;
|
|
expect(folder.children).toHaveLength(2);
|
|
});
|
|
|
|
it("collapses a linear chain into one folder", () => {
|
|
const root = buildTree([session("a", "/home/n/git/naps62/maestro")], "/home/n") as TreeFolder;
|
|
expect(names(root)).toEqual(["~/git/naps62/maestro"]);
|
|
});
|
|
|
|
it("folds the sole root folder into each of its own children when it branches", () => {
|
|
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"]);
|
|
});
|
|
|
|
it("stops collapsing where a directory branches", () => {
|
|
const root = buildTree(
|
|
[session("a", "/home/n/git/one"), session("b", "/home/n/git/two")],
|
|
"/home/n",
|
|
) as TreeFolder;
|
|
|
|
expect(names(root)).toEqual(["~/git"]);
|
|
const git = root.children[0] as TreeFolder;
|
|
expect(names(git).sort()).toEqual(["one", "two"]);
|
|
});
|
|
|
|
it("sorts folders before leaves, then by name", () => {
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("mergeLinear", () => {
|
|
it("keeps the deepest path so collapse state survives growth", () => {
|
|
const folder: TreeFolder = {
|
|
kind: "folder",
|
|
name: "a",
|
|
path: "a",
|
|
children: [{ kind: "folder", name: "b", path: "a/b", children: [] }],
|
|
};
|
|
|
|
const merged = mergeLinear(folder);
|
|
expect(merged.name).toBe("a/b");
|
|
expect(merged.path).toBe("a/b");
|
|
});
|
|
});
|
|
|
|
describe("worktree sessions group under their repository (#99)", () => {
|
|
/** A session the daemon spawned into a worktree of `repo`. */
|
|
function worktreeSession(id: string, repo: string, wt: string): Session {
|
|
return { ...session(id, wt), worktree: { path: wt, base_repo: repo } } as Session;
|
|
}
|
|
|
|
/** Top-level folder names, after linear chains are collapsed. */
|
|
function topFolders(root: TreeFolder): string[] {
|
|
return root.children.filter((c) => c.kind === "folder").map((c) => (c as TreeFolder).name);
|
|
}
|
|
|
|
it("nests under the repo, not the worktree directory", () => {
|
|
const root = buildTree(
|
|
[
|
|
worktreeSession(
|
|
"a",
|
|
"/home/n/tea/agent-skills",
|
|
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
|
|
),
|
|
],
|
|
"/home/n",
|
|
) as TreeFolder;
|
|
|
|
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
|
const repo = root.children[0] as TreeFolder;
|
|
expect(repo.children).toHaveLength(1);
|
|
expect(repo.children[0].kind).toBe("leaf");
|
|
});
|
|
|
|
it("puts a repo's plain and worktree sessions side by side", () => {
|
|
const root = buildTree(
|
|
[
|
|
session("plain", "/home/n/tea/agent-skills"),
|
|
worktreeSession(
|
|
"wt",
|
|
"/home/n/tea/agent-skills",
|
|
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
|
|
),
|
|
],
|
|
"/home/n",
|
|
) as TreeFolder;
|
|
|
|
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
|
const repo = root.children[0] as TreeFolder;
|
|
expect(repo.children).toHaveLength(2);
|
|
expect(repo.children.every((c) => c.kind === "leaf")).toBe(true);
|
|
});
|
|
|
|
it("leaves a session without a worktree where it is", () => {
|
|
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n") as TreeFolder;
|
|
expect(topFolders(root)).toEqual(["~/tea/arr"]);
|
|
});
|
|
});
|