Files
hermes-agent/web/src/lib/chat-workspaces.test.ts
teknium1 d9ca819cc4 Inspired by Amp: pick the workspace a dashboard chat starts in
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.
2026-09-23 03:12:04 -07:00

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");
});
});