A fresh dashboard /chat always spawned the TUI in the dashboard process's launch directory, so from a phone or any browser there was no way to aim a new session at a specific repository. Amp's runners now serve many directories and the web composer offers a picker of the runner's projects and discovered git checkouts; this ports that mechanism onto the surface Hermes already has: the dashboard is the phone/web front, the host's projects.db + repo-discovery cache are the served directories. - GET /api/chat/workspaces: the profile's projects (with folders) and discovered repos (session-derived + scanned), default_cwd, home; ?scan=1 rescans desktop.repo_scan_roots on the host, so headless installs (no Desktop to populate the cache) discover repos too. - /api/pty?cwd=<dir>: validated (existing directory, fail-closed 400 via the PTY error path) and forwarded to the TUI child as HERMES_CWD (self-spawned gateway cwd) + HERMES_TUI_CWD (explicit cwd on session.create for the dashboard's in-memory gateway, whose own cwd is the launch dir). Resumed sessions ignore it. - ui-tui: session.create carries cwd when HERMES_TUI_CWD is set, so /new inside a dashboard chat stays in the picked workspace too. - web: workspace selector in the Chat rail above "New chat" (projects, repos by recency, Other path…, rescan), remembered per profile in localStorage; 17 locales. - docs: web-dashboard.md rail + REST sections. Live E2E: real `hermes dashboard` under a scratch HOME with two git repos under desktop.repo_scan_roots -> /api/chat/workspaces?scan=1 lists both; /api/pty?cwd=repo-a -> TUI status bar shows ~/code/repo-a; /api/pty?cwd=<missing> -> "Working directory does not exist" + close.
62 lines
2.0 KiB
TypeScript
62 lines
2.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { ChatWorkspacesResponse } from "@/lib/api";
|
|
import { abbreviateHomePath, workspaceOptions } from "@/lib/chat-workspaces";
|
|
|
|
const base: ChatWorkspacesResponse = {
|
|
projects: [],
|
|
repos: [],
|
|
default_cwd: "/home/u/.hermes",
|
|
home: "/home/u",
|
|
scan_enabled: true,
|
|
};
|
|
|
|
describe("workspaceOptions", () => {
|
|
it("lists project folders first, then repos by recency, deduped on path", () => {
|
|
const data: ChatWorkspacesResponse = {
|
|
...base,
|
|
projects: [
|
|
{
|
|
id: "p1",
|
|
slug: "site",
|
|
name: "Site",
|
|
primary_path: "/home/u/code/site",
|
|
archived: false,
|
|
folders: [
|
|
{ path: "/home/u/code/site", label: null, is_primary: true },
|
|
{ path: "/home/u/code/site-infra", label: "infra", is_primary: false },
|
|
],
|
|
},
|
|
{
|
|
id: "p2",
|
|
slug: "old",
|
|
name: "Old",
|
|
primary_path: "/home/u/code/old",
|
|
archived: true,
|
|
folders: [{ path: "/home/u/code/old", label: null, is_primary: true }],
|
|
},
|
|
],
|
|
repos: [
|
|
{ root: "/home/u/code/site", label: "site", sessions: 3, last_active: 50 },
|
|
{ root: "/home/u/code/lib", label: "lib", sessions: 1, last_active: 10 },
|
|
{ root: "/home/u/code/app", label: "app", sessions: 2, last_active: 90 },
|
|
],
|
|
};
|
|
|
|
expect(workspaceOptions(data)).toEqual([
|
|
{ value: "/home/u/code/site", label: "Site · ~/code/site" },
|
|
{ value: "/home/u/code/site-infra", label: "Site · infra" },
|
|
{ value: "/home/u/code/app", label: "app ~/code/app" },
|
|
{ value: "/home/u/code/lib", label: "lib ~/code/lib" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("abbreviateHomePath", () => {
|
|
it("collapses the home prefix only on a path boundary", () => {
|
|
expect(abbreviateHomePath("/home/u/code/x", "/home/u")).toBe("~/code/x");
|
|
expect(abbreviateHomePath("/home/u", "/home/u/")).toBe("~");
|
|
expect(abbreviateHomePath("/home/user2/code", "/home/u")).toBe("/home/user2/code");
|
|
});
|
|
});
|