Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 beef2049ed fix(maestro): update remaining id="app" references in web tests
ci / rust (push) Failing after 6s
ci / web (push) Successful in 37s
ci / flake (push) Failing after 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:02:02 +01:00
Miguel PalhasandClaude Opus 4.6 c30592e6b6 fix(maestro): update embedded_bundle test for React mount point
ci / web (push) Failing after 6s
ci / flake (push) Failing after 1m4s
ci / rust (push) Failing after 1m51s
The React migration changed the SPA root from id="app" to id="root".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 17:07:21 +01:00
Miguel PalhasandClaude Opus 4.6 abae81a0ce feat(web): React + Tailwind migration
ci / web (push) Successful in 37s
ci / flake (push) Failing after 1m6s
ci / rust (push) Failing after 1m56s
Replaces the 12.7k-line vanilla TypeScript SPA with React + Zustand +
Tailwind. 16 issues across milestone 48.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 17:01:49 +01:00
Miguel PalhasandClaude Opus 4.6 66a568818a fix(maestro): re-read session before sidecar write in mark_session_dead
ci / web (push) Failing after 6s
ci / flake (push) Failing after 55s
ci / rust (push) Successful in 6m7s
mark_session_dead_with_worktree_settlement cloned the session under the
lock, dropped it, did async work, then wrote the stale clone to the
sidecar. A haiku rename (or any other metadata update) that landed
between the clone and the write was silently overwritten, so the sidecar
reverted to name_source: Generated and the next daemon recovery
re-triggered autoname.

Re-read record.session under the lock at write time so the sidecar
always reflects the latest state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 15:14:34 +01:00
Miguel Palhas f3d6146884 Closes #254: port design kit specimen page to React 2026-09-09 15:13:48 +01:00
Miguel Palhas 35ae01db93 feat(maestro): port design kit specimen page to React
Replaces the deleted kit.ts + kit.css with DesignKit.tsx, gated behind
import.meta.env.DEV and loaded on ?kit. Shows the full tailwind.config.ts
token mapping (colours across all 6 palettes, typography, spacing,
radii, z-index) plus buttons, ActivityRail states, SessionCard states,
and terminal chrome, all switchable live through ThemeContext.

Closes #254
2026-09-09 15:11:48 +01:00
Miguel Palhas 8a05ef2eb7 feat(maestro): wire React app as sole web entry point
Deletes the vanilla shell and mounts App.tsx on #root. main.ts and the
23 views it drove are gone; App.tsx composes the ported components —
sidebar, multi-view, command bar, spawn modal, help and restart
overlays, daemon link, drawer — around the catalog and layout stores.

The pure parts of four deleted modules moved next to the components
that still use them: the spawn-form model and spawnSimilar into
components/spawn, the transcript's markdown/format helpers and the diff
into components/structured, the help overlay's grouping into
components/dialogs. Document-level event names now live in uiEvents.ts.

Deduplicated two pairs the migration left side by side: the sidebar
uses dialogs/KillDialog and common/ContextMenu. It also listens for the
command bar's kill/rename events, which nothing had picked up since
catalog.ts went away.

styles.css stays as the token and base layer, with `#app` renamed to
`#root` and the pane-body rules re-keyed off the classes the React
panes actually carry.

Closes #253
2026-09-09 02:27:42 +01:00
Miguel Palhas ad71147bd9 Closes #252: port all tests to React Testing Library 2026-09-09 02:07:52 +01:00
Miguel Palhas e9c3882cea Closes #242: port daemon link and restart overlay to React 2026-09-09 02:07:49 +01:00
Miguel Palhas bc87ddee06 feat(web): port daemonLink and restartOverlay to React (#242)
DaemonLink.tsx holds the link state machine as a Zustand store (same
shape as catalogStore/connectionStore) plus a thin controller component
wiring the catalog store's `connected` field to it. RestartOverlay.tsx
renders the blocking modal for down/updating, reusing the existing CSS.

Closes #242
2026-09-09 01:59:49 +01:00
Miguel Palhas 554c9ebb31 Closes #240: add useHashRouter hook 2026-09-09 01:51:02 +01:00
Miguel PalhasandClaude Sonnet 5 af5ad1a227 feat(web): add useHashRouter hook for React shell (#240)
Ports main.ts's parseRoute/dispatch hash parsing into a React hook:
same #/ and #/attach/:id shape, current route plus a navigate function.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 01:43:46 +01:00
112 changed files with 1937 additions and 18804 deletions
+12 -12
View File
@@ -2916,17 +2916,16 @@ async fn mark_session_dead_with_worktree_settlement(
// Release anyone attached; the scrollback survives for a later
// attach.
record.bridge.seal();
let session = record.session.clone();
let worktree = record
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone());
Some((session, worktree))
Some(worktree)
}
_ => None,
}
};
let Some((mut session, worktree)) = flipped else {
let Some(worktree) = flipped else {
return false;
};
let stream = inner
@@ -2943,9 +2942,6 @@ async fn mark_session_dead_with_worktree_settlement(
if settle_worktree {
if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt).await {
// The tree is settled (removed); a stale "in the way" would
// describe something the session no longer holds.
session.blocked_work = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
@@ -2957,13 +2953,17 @@ async fn mark_session_dead_with_worktree_settlement(
}
}
// Settle has now been applied or refused either way (#115): from here on
// its outcomes are safe to observe.
{
// its outcomes are safe to observe. Re-read the session from the record
// so the sidecar write includes any updates (e.g. a haiku rename) that
// landed between the initial clone and now.
let session = {
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.settled = true;
}
}
let Some(record) = sessions.get_mut(&id) else {
return false;
};
record.settled = true;
record.session.clone()
};
let _ = inner.sidecar.write(&session);
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
+37 -3
View File
@@ -11,14 +11,14 @@ use common::TmuxFixture;
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
use tempfile::TempDir;
use uuid::Uuid;
struct Fixture {
manager: SessionManager,
sidecar: SidecarStore,
_tmux: TmuxFixture,
tmux: TmuxFixture,
_state_dir: TempDir,
}
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
Fixture {
manager,
sidecar,
_tmux: tmux,
tmux,
_state_dir: state_dir,
}
}
@@ -171,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
let live = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&live), NameSource::Generated);
}
/// A session that dies naturally (liveness watcher marks it dead) must not
/// overwrite a haiku rename that landed before the death. Regression test for
/// the stale-clone sidecar write in `mark_session_dead`.
#[tokio::test]
async fn session_death_preserves_the_haiku_name() {
let fx = fixture();
let id = spawn(&fx.manager, None).await;
fx.manager.claim_autoname(id).await.unwrap();
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
// Verify the sidecar has Haiku before the session dies.
let before = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&before), NameSource::Haiku);
// Kill the tmux session directly so the liveness watcher picks it up.
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
fx.manager
.start_liveness_watcher(std::time::Duration::from_millis(25));
common::wait_for_async(std::time::Duration::from_secs(3), || async {
fx.manager.status(id).await == Some(SessionStatus::Dead)
})
.await;
let after = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(
display::name_source(&after),
NameSource::Haiku,
"mark_session_dead must not overwrite a haiku rename in the sidecar"
);
assert_eq!(display::custom_name(&after), Some("haiku-name"));
}
@@ -13,14 +13,14 @@ async fn embedded_index_is_real_spa_not_placeholder() {
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains(r#"id="app""#),
html.contains(r#"id="root""#),
"served index is missing the SPA mount point; got:\n{html}"
);
assert!(
!html.contains("maestro is running"),
"served index is the placeholder, not the built bundle"
);
// Stronger signal than `id="app"` (which appears in both source and built
// Stronger signal than `id="root"` (which appears in both source and built
// HTML): the vite build rewrites the entry to a hashed `/assets/index-<hash>`
// bundle, whereas the un-built source references `/src/main.ts`. This catches
// both an empty/placeholder embed and the staleness case.
+2 -2
View File
@@ -59,7 +59,7 @@ async fn root_serves_the_spa_shell() {
.map(|v| v.starts_with("text/html")),
Some(true)
);
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
}
#[tokio::test]
@@ -69,7 +69,7 @@ async fn client_side_route_falls_back_to_the_shell() {
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert!(resp.text().await.unwrap().contains(r#"id="app""#));
assert!(resp.text().await.unwrap().contains(r#"id="root""#));
}
/// An unmatched API path must 404 rather than fall through to the SPA shell:
+1 -2
View File
@@ -23,8 +23,7 @@
<meta name="apple-mobile-web-app-title" content="maestro" />
</head>
<body>
<div id="app"></div>
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+441 -2
View File
@@ -1,9 +1,448 @@
import { ThemeProvider } from "./contexts/ThemeContext";
// The app shell (#253): everything main.ts used to wire by hand, as one React
// tree. The stores are the state — a catalog store fed by the events socket, a
// layout store holding the tab/split tree — and the hash route is a deep link
// into the latter.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "zustand";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "@fontsource/jetbrains-mono/600.css";
import "@fontsource/jetbrains-mono/700.css";
import { createSession, fetchNames, getDaemonInfo, gitRoot, projects as fetchProjects } from "./api.js";
import {
CommandSet,
DEFAULT_LEADER,
controlByte,
defineCommands,
isBarTypingTarget,
} from "./commands.js";
import { ThemeProvider } from "./contexts/ThemeContext.js";
import { CommandBar, SPAWN_DIRECTORY_EVENT, useCommandBar } from "./components/commandBar/index.js";
import { DaemonLink, createDaemonLinkStore } from "./components/daemon/DaemonLink.js";
import { RestartOverlay } from "./components/daemon/RestartOverlay.js";
import { HelpOverlay } from "./components/dialogs/index.js";
import { useViewport } from "./components/hooks/useViewport.js";
import { Drawer } from "./components/layout/Drawer.js";
import { MultiView, type MultiViewHandle } from "./components/layout/MultiView.js";
import { Sidebar } from "./components/sidebar/index.js";
import {
SpawnModal,
spawnSimilarProjectSeed,
spawnSimilarSeed,
useFastForward,
type SpawnFormProps,
} from "./components/spawn/index.js";
import { buildFastForwardRequest, fastForwardSeed, resolveFastForward } from "./fastForward.js";
import { useHashRouter } from "./hooks/useHashRouter.js";
import { usePreview } from "./hooks/usePreview.js";
import { activeTab, findLeaf, openSessions } from "./layout.js";
import { createCatalogStore, type CatalogStore } from "./stores/catalogStore.js";
import { createLayoutStore, type LayoutStore } from "./stores/layoutStore.js";
import type { Project, Session, SpawnPreset, Uuid } from "./types.js";
import {
FOCUS_PANE_EVENT,
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
requestOpenSpawn,
requestToggleDrawer,
} from "./uiEvents.js";
type SpawnSeed = SpawnFormProps["seed"];
/** Open modal state: `seed` is what the form starts from (undefined for a
* plain spawn), `newTab` where the created session lands. */
interface SpawnState {
seed: SpawnSeed;
newTab: boolean;
}
export function App() {
return (
<ThemeProvider>
<div id="react-root" />
<Shell />
</ThemeProvider>
);
}
function Shell() {
useViewport();
// One instance each, for the life of the page.
const catalogStore = useMemo(() => createCatalogStore(), []);
const layoutStore = useMemo(() => createLayoutStore(), []);
const linkStore = useMemo(() => createDaemonLinkStore(), []);
const sessions = useStore(catalogStore, (s: CatalogStore) => s.sessions);
const connected = useStore(catalogStore, (s: CatalogStore) => s.connected);
const loaded = useStore(catalogStore, (s: CatalogStore) => s.loaded);
const layout = useStore(layoutStore, (s: LayoutStore) => s.layout);
const [homeDir, setHomeDir] = useState<string | undefined>(undefined);
const [leader, setLeader] = useState(DEFAULT_LEADER);
const [spawn, setSpawn] = useState<SpawnState | null>(null);
const { route, navigate } = useHashRouter();
const { preview, previewedSessionId, commit: commitPreview, end: endPreview } = usePreview({
store: layoutStore,
});
useEffect(() => {
catalogStore.getState().start();
return () => catalogStore.getState().stop();
}, [catalogStore]);
// The daemon's home dir abbreviates cwds to `~/…`; its leader rebinds the
// chords hanging off it (#159), so the `?` overlay and the command bar show
// the configured chord rather than the default. Absolute paths and the
// default leader are fine fallbacks.
useEffect(() => {
void getDaemonInfo()
.then((info) => {
setHomeDir(info.home_dir);
if (info.leader) setLeader(info.leader);
})
.catch(() => {});
}, []);
// Installed mobile browsers suspend timers and WebSockets in the background.
// Replacing the socket on return avoids leaving the restart overlay up after
// a transient lost connection has already recovered.
useEffect(() => {
const recover = () => {
if (document.visibilityState !== "visible") return;
void linkStore.getState().probe();
catalogStore.getState().reconnect();
};
document.addEventListener("visibilitychange", recover);
window.addEventListener("pageshow", recover);
window.addEventListener("online", recover);
return () => {
document.removeEventListener("visibilitychange", recover);
window.removeEventListener("pageshow", recover);
window.removeEventListener("online", recover);
};
}, [catalogStore, linkStore]);
// The catalog is the authority on which sessions exist: a pane for a session
// that is no longer listed gets closed here, rather than waiting on its own
// socket to notice. Only once a list has actually landed — before that,
// "not listed" means "not known yet" and would close every restored pane.
useEffect(() => {
if (!loaded) return;
for (const id of openSessions(layout)) {
if (!sessions.has(id)) layoutStore.getState().closeSession(id);
}
}, [loaded, sessions, layout, layoutStore]);
const tab = activeTab(layout);
const focusedSessionId =
(tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)?.sessionId) || null;
const focusedSession = focusedSessionId ? sessions.get(focusedSessionId) : undefined;
/** Open/focus a session, settling any preview of it in place. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely
* on `hashchange` alone — a split or drag can move focus without the hash
* changing, leaving a click back onto that row silently ignored. */
const focusSession = useCallback(
(sessionId: Uuid) => {
// `commit` cancels a pending preview and opens the session; the layout
// store settles a matching preview in place rather than moving it twice.
commitPreview(sessionId);
navigate({ kind: "attach", sessionId });
},
[commitPreview, navigate],
);
// A deep link (`#/attach/:id`) opens the session it names. Only the route
// *changing* does this: focusing a pane rewrites the hash, and re-opening
// from that would fight the layout the operator just arranged.
const lastRouteRef = useRef<string | null>(null);
useEffect(() => {
const key = route.kind === "attach" ? route.sessionId : "catalog";
if (lastRouteRef.current === key) return;
lastRouteRef.current = key;
if (route.kind === "attach") layoutStore.getState().openSession(route.sessionId);
}, [route, layoutStore]);
/** Show a freshly spawned session: it gets its own pane rather than evicting
* whatever the operator was reading. */
const mountSpawned = useCallback(
(session: Session, opts?: { newTab?: boolean }) => {
layoutStore.getState().openSessionBeside(session.id, opts);
navigate({ kind: "attach", sessionId: session.id });
},
[layoutStore, navigate],
);
const openSpawn = useCallback(
(seed: SpawnSeed, newTab: boolean) => {
endPreview();
setSpawn({ seed, newTab });
},
[endPreview],
);
/** The plain spawn: the session you were last on preselects its project, so
* `n` then Enter spawns another session there without touching the picker.
* It lands in a new tab, not a split of the pane you were reading (#100). */
const openPlainSpawn = useCallback(() => {
openSpawn(focusedSession ? spawnSimilarProjectSeed(focusedSession) : undefined, true);
}, [focusedSession, openSpawn]);
/** The focused session's project — its base repo for a worktree session, its
* cwd otherwise (the rule spawnSimilarProjectSeed uses). */
const focusedProjectCwd = focusedSession
? (focusedSession.worktree?.base_repo ?? focusedSession.cwd)
: undefined;
const fastForward = useFastForward({
focusedCwd: focusedProjectCwd,
onSpawned: (session) => mountSpawned(session),
onFallback: (seed) => openSpawn(seed, false),
});
const bar = useCommandBar({ onOpen: endPreview });
useEffect(() => {
const onOpenSpawnEvent = () => openPlainSpawn();
const onSpawnSimilar = (e: Event) => {
const s = (e as CustomEvent<Session>).detail;
if (s) openSpawn(spawnSimilarSeed(s), false);
};
const onSessionSelect = (e: Event) => focusSession((e as CustomEvent<Uuid>).detail);
document.addEventListener(OPEN_SPAWN_EVENT, onOpenSpawnEvent);
document.addEventListener(SPAWN_SIMILAR_EVENT, onSpawnSimilar);
document.addEventListener(SESSION_SELECT_EVENT, onSessionSelect);
return () => {
document.removeEventListener(OPEN_SPAWN_EVENT, onOpenSpawnEvent);
document.removeEventListener(SPAWN_SIMILAR_EVENT, onSpawnSimilar);
document.removeEventListener(SESSION_SELECT_EVENT, onSessionSelect);
};
}, [openPlainSpawn, openSpawn, focusSession]);
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
// fast-forward spawn, aimed at the chosen directory instead of the most
// recent project.
useEffect(() => {
const onSpawnDirectory = (e: Event) => {
void spawnInDirectory((e as CustomEvent<string>).detail, mountSpawned, (seed) =>
openSpawn(seed, false),
);
};
document.addEventListener(SPAWN_DIRECTORY_EVENT, onSpawnDirectory);
return () => document.removeEventListener(SPAWN_DIRECTORY_EVENT, onSpawnDirectory);
}, [mountSpawned, openSpawn]);
// Tab in the session list hands the keyboard to the pane; leader-h hands it
// back the other way. Handing it to a previewed pane is choosing it: the
// preview settles rather than snapping back when the list blurs.
const sidebarRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<MultiViewHandle>(null);
const focusPane = useCallback(() => {
if (previewedSessionId) commitPreview(previewedSessionId);
viewRef.current?.focusPane();
}, [previewedSessionId, commitPreview]);
useEffect(() => {
document.addEventListener(FOCUS_PANE_EVENT, focusPane);
return () => document.removeEventListener(FOCUS_PANE_EVENT, focusPane);
}, [focusPane]);
// The global half of the command registry (#148). `n` opens the spawn modal
// and `N` fast-forwards a spawn (#67); both are plain keys, so the set's own
// `isTypingTarget` keeps them out of form fields and the terminal's hidden
// textarea. Leader-h moves the keyboard between the list and the pane — a
// chord, because an attached terminal owns every plain keystroke
// (docs/COMMAND-BAR.md).
useEffect(() => {
if (bar.open || spawn) return;
const spawnKeys = new CommandSet(
defineCommands({
"spawn.open": openPlainSpawn,
"spawn.fastForward": () => void fastForward(),
}),
);
const focusKeys = new CommandSet(
defineCommands(
{
"focus.toggle": () => {
if (sidebarRef.current?.contains(document.activeElement)) {
focusPane();
} else {
// No-op on desktop; on mobile the list is off-canvas until the
// drawer opens, so the focus has somewhere to land.
requestToggleDrawer();
sidebarRef.current?.querySelector<HTMLElement>("[tabindex]")?.focus();
}
},
// The escape hatch: binding ctrl-k takes readline's
// kill-to-end-of-line away from every pane, and leader ctrl-k gives
// it back as the literal 0x0B byte (docs/COMMAND-BAR.md).
"pane.literalCtrlK": () => viewRef.current?.sendInput("\x0b"),
// The leader pressed twice, tmux-style: one literal leader byte,
// built from the leader itself so a rebound one sends its own (#158).
"pane.literalLeader": () => viewRef.current?.sendInput(controlByte(leader)),
},
leader,
),
{ leader },
);
const onKeydown = (e: KeyboardEvent) => {
if (focusKeys.handle(e)) return;
if (isBarTypingTarget(e.target)) return;
spawnKeys.handle(e);
};
window.addEventListener("keydown", onKeydown, true);
return () => window.removeEventListener("keydown", onKeydown, true);
}, [bar.open, spawn, openPlainSpawn, fastForward, focusPane, leader]);
const openIds = useMemo(() => openSessions(layout), [layout]);
const barSessions = useMemo(() => [...sessions.values()], [sessions]);
return (
<>
<Drawer
sidebar={
<div ref={sidebarRef} className="contents">
<Sidebar
store={catalogStore}
homeDir={homeDir}
activeId={focusedSessionId}
openIds={openIds}
previewId={previewedSessionId}
onSelect={(id) => focusSession(id)}
onPreview={preview}
onSpawn={openPlainSpawn}
onSpawnDefaults={() => void fastForward()}
onSpawnSimilar={(s) => openSpawn(spawnSimilarSeed(s), false)}
onFocusPane={focusPane}
/>
</div>
}
>
<MultiView
ref={viewRef}
store={layoutStore}
// Undefined until the first list lands: MultiView's boot
// reconciliation prunes against it, and an empty map would take
// every restored pane with it.
sessions={loaded ? sessions : undefined}
onDisconnect={(id) => layoutStore.getState().closeSession(id)}
emptyState={<Welcome onSpawn={requestOpenSpawn} />}
/>
</Drawer>
<DaemonLink store={linkStore} connected={connected} />
<RestartOverlay store={linkStore} />
<HelpOverlay />
{bar.open && (
<CommandBar
sessions={barSessions}
homeDir={homeDir}
onOpenSession={(id) => {
bar.closeBar();
focusSession(id);
}}
onClose={bar.closeBar}
/>
)}
{spawn && (
<SpawnModal
seed={spawn.seed}
onSuccess={(session) => mountSpawned(session, { newTab: spawn.newTab })}
onClose={() => setSpawn(null)}
/>
)}
</>
);
}
async function spawnPresets(): Promise<SpawnPreset[]> {
try {
const info = await getDaemonInfo();
if (info.presets?.length) return info.presets;
} catch {
// keep fallbacks
}
const { FALLBACK_PRESETS } = await import("./components/spawn/model.js");
return FALLBACK_PRESETS;
}
/** A directory the project scan never saw stands in as its own project; one
* outside any repo spawns without a worktree, since there is no repo to cut a
* branch from. Anything still unresolved opens the pre-filled form. */
async function spawnInDirectory(
path: string,
onSpawned: (session: Session) => void,
onFallback: (seed: SpawnSeed) => void,
): Promise<void> {
const [discovered, handle] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
const presets = await spawnPresets();
const known = discovered.find((p) => p.path === path);
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
const resolved = resolveFastForward([project], presets, handle, path);
const req = buildFastForwardRequest(resolved);
if (req) {
const inRepo =
known !== undefined ||
(await gitRoot(path)
.then((r) => !!r.root)
.catch(() => false));
if (!inRepo) delete req.worktree;
try {
onSpawned(await createSession(req));
return;
} catch {
// A failed POST degrades to the pre-filled form, never a dead row.
}
}
onFallback(fastForwardSeed(resolved));
}
/** The content pane with nothing open. The ☰ is mobile-only: with no pane
* there is no pane header to carry it, so the drawer would be swipe-only. */
function Welcome({ onSpawn }: { onSpawn(): void }) {
return (
<div className="empty content-welcome">
<div className="empty-glyph" aria-hidden="true">
⌘
</div>
<div className="empty-title">No session open</div>
<p className="empty-hint">
Pick a session from the list, or start a new one. Open sessions keep streaming in the
background — switching back is instant.
</p>
<p className="empty-hint">
Press <kbd>?</kbd> for keyboard shortcuts.
</p>
<div className="empty-actions">
<button
type="button"
className="btn btn-ghost welcome-sessions"
onClick={() => requestToggleDrawer()}
>
☰ Sessions
</button>
<button
type="button"
className="btn btn-primary"
title="Draft pane: n — fast-forward spawn: N"
onClick={onSpawn}
>
+ Spawn session
</button>
</div>
</div>
);
}
-659
View File
@@ -1,659 +0,0 @@
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
import { requestToggleDrawer } from "./drawer.js";
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
import type { PaneConnection } from "./pane.js";
import type { ClientControl, Uuid } from "./types.js";
import { WebglPolicy } from "./webglRenderer.js";
export type { AttachInfo } from "./paneTitle.js";
/** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */
const webglPolicy = new WebglPolicy();
/**
* A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
*
* The connection is **decoupled from the UI**: it owns a long-lived WS that
* keeps streaming PTY output into the terminal buffer whether or not the view
* is currently mounted. The shell moves `el` between the visible content pane
* and a hidden cache; `onMount`/`onUnmount` only adjust focus + sizing, never
* the connection. Only `dispose()` (eviction, an explicit disconnect, or the
* session ending) tears the socket down — so switching away and back is
* instant, with scrollback already in place.
*/
export interface AttachConnection extends PaneConnection {}
export interface AttachOptions {
/** Invoked when the user clicks the view's explicit "disconnect" control. */
onRequestClose?: (sessionId: Uuid) => void;
/** Invoked when the socket closes on its own — the session ended or the link
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
* shell uses this to auto-close the pane rather than leave a dead one. */
onClosed?: (sessionId: Uuid) => void;
/** Asked on every close: should the socket be retried? The shell answers
* from the catalog — see `shouldRetryClose`. */
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
}
export function createAttachConnection(
sessionId: Uuid,
opts: AttachOptions = {},
info?: AttachInfo,
): AttachConnection {
const el = document.createElement("div");
el.className = "attach";
// Useful identity over a bare UUID: name · command. The full id stays in the
// tooltip for when you really need it.
const titleHtml = paneTitleHtml(sessionId, info);
const titleAttr = paneTitleAttr(sessionId, info);
el.innerHTML = `
<header class="attach-header">
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${titleAttr}">${titleHtml}</span>
<span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
</header>
<div class="attach-body"></div>
`;
const titleEl = el.querySelector(".attach-title") as HTMLSpanElement;
const body = el.querySelector(".attach-body") as HTMLDivElement;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
const keybar = buildKeyBar((data) => sendInput(data));
el.appendChild(keybar.el);
const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => {
stateEl.textContent = s;
stateEl.dataset.state = kind;
};
// Mobile-only (CSS hides it on desktop, where the sidebar is always in view):
// the session list is an off-canvas drawer and this is what opens it.
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
requestToggleDrawer(),
);
disconnectBtn.addEventListener("click", () => {
opts.onRequestClose?.(sessionId);
});
const term = new Terminal({
cursorBlink: true,
fontFamily:
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
theme: {
// The terminal stays dark in both app themes (convention); colors are
// tuned to the maestro palette — signal-green cursor, slate background.
background: "#121419",
foreground: "#e6e8ec",
cursor: "#5fe3a1",
cursorAccent: "#121419",
selectionBackground: "#2b6b4f",
},
scrollback: 5000,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(body);
// GPU rendering is scoped to the mounted view, not the connection: acquired
// in onMount, released in onUnmount. See webglRenderer.ts.
let webgl: WebglAddon | null = null;
let dprMedia: MediaQueryList | null = null;
// The glyph atlas is rasterised for one device pixel ratio. Dragging the
// window to a display with a different one (or an Android browser-zoom
// change) leaves every cell blurry until the atlas is rebuilt.
const onDprChange = () => {
try {
term.clearTextureAtlas();
} catch {
// ignore — nothing to rebuild if the renderer already went away
}
watchDpr();
};
function watchDpr(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
try {
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
dprMedia?.addEventListener("change", onDprChange);
} catch {
// ignore — a browser without `resolution` media queries just keeps the
// atlas it has, which is the pre-WebGL behaviour anyway
}
}
function releaseWebgl(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
if (!webgl) return;
const addon = webgl;
webgl = null;
try {
addon.dispose(); // xterm falls back to the DOM renderer
} catch {
// ignore — a lost context can throw on the way out
}
}
function acquireWebgl(): void {
if (webgl || disposed || !webglPolicy.enabled()) return;
try {
const addon = new WebglAddon();
// Context loss is routine on Android — backgrounding the tab is enough.
// Drop back to the DOM renderer, then retry only while the shared budget
// allows and this view is still the one on screen.
addon.onContextLoss(() => {
webglPolicy.noteContextLoss();
releaseWebgl();
if (mounted) acquireWebgl();
});
term.loadAddon(addon);
webgl = addon;
watchDpr();
} catch {
// Context creation failed outright (no GPU, or the browser is already at
// its context cap). Count it like a loss so we stop asking, and stay on
// the DOM renderer.
webglPolicy.noteContextLoss();
webgl = null;
}
}
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback.
// • mouse-tracking apps (tmux, Claude Code on the alt screen) → synthesize
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
// scrolling *its* content (the only scroll that exists on the alt screen).
// • alt screen with no mouse tracking → nothing to do; the app owns input.
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
// gesture stream; only the terminal surface opts out of native page panning.
// Listeners MUST sit on `.xterm`, not `.xterm-viewport`: `.xterm-screen` is a
// later sibling of the viewport, so it paints over it and takes every touch.
{
const root = term.element as HTMLElement | undefined;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
const cellH = () => {
const core = (term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { height: number } } } } };
})._core;
return core?._renderService?.dimensions?.css?.cell?.height || 16;
};
if (root) {
let lastY = 0;
let lastX = 0;
let remainder = 0;
root.addEventListener(
"touchstart",
(e) => {
if (e.touches.length !== 1) return;
lastY = e.touches[0].clientY;
lastX = e.touches[0].clientX;
remainder = 0;
},
{ passive: true },
);
root.addEventListener(
"touchmove",
(e) => {
if (e.touches.length !== 1) return;
const ch = cellH();
const y = e.touches[0].clientY;
const px = lastY - y + remainder; // finger up → scroll toward newer
lastY = y;
const lines = Math.trunc(px / ch);
remainder = px - lines * ch;
if (lines === 0) return;
// term.modes.mouseTrackingMode is "none" unless the app turned mouse
// reporting on — that's the signal to forward instead of scrollback.
const mouseTracked = term.modes.mouseTrackingMode !== "none";
if (mouseTracked && screen) {
// One wheel event per crossed cell, dispatched at the touch point so
// xterm computes valid cell coords for the mouse report.
const dir = lines > 0 ? 1 : -1;
for (let i = 0; i < Math.abs(lines); i++) {
screen.dispatchEvent(
new WheelEvent("wheel", {
deltaY: dir * ch,
deltaMode: 0,
clientX: lastX,
clientY: y,
bubbles: true,
cancelable: true,
}),
);
}
e.preventDefault();
} else if (term.buffer.active.type !== "alternate") {
term.scrollLines(lines);
e.preventDefault();
}
},
{ passive: false },
);
}
}
// Mobile: the special-key bar is only useful while the soft keyboard is up,
// so reveal it only when the terminal's hidden textarea is focused (CSS still
// gates it to coarse pointers). Tapping a keycap keeps focus via pointerdown
// preventDefault, so the bar stays put during use. Re-fit on the toggle since
// showing/hiding the bar changes the terminal's available height.
const ta = term.textarea;
if (ta) {
ta.addEventListener("focus", () => {
keybar.el.classList.add("keybar-active");
requestAnimationFrame(tryFit);
});
ta.addEventListener("blur", () => {
keybar.el.classList.remove("keybar-active");
requestAnimationFrame(tryFit);
});
}
let mounted = false;
// A previewed pane is visible but must not steal the keyboard from the
// sidebar, so mounting can opt out of the focus grab (and out of the one
// the socket does when it finishes connecting).
let focusOnMount = true;
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
// stops ~1–2 columns short of the edge. Same math, minus that subtraction.
const tryFit = () => {
if (!mounted) return; // parked views have no meaningful size to fit to
try {
const el2 = term.element;
const parent = el2?.parentElement;
const core = (term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
})._core;
const cell = core?._renderService?.dimensions?.css?.cell;
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
fit.fit(); // fall back if internals aren't ready yet
return;
}
// Measure the viewport's real inner width (clientWidth excludes any
// scrollbar), not the container's — so columns always fit inside the
// viewport whether or not xterm reserves a scrollbar. Falls back to the
// padded container box before the viewport exists.
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
let availW: number;
let availH: number;
if (vp && vp.clientWidth > 0) {
availW = vp.clientWidth;
availH = vp.clientHeight;
} else {
const ps = getComputedStyle(parent);
const ts = getComputedStyle(el2);
availW =
parseInt(ps.width) -
(parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
availH =
parseInt(ps.height) -
(parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
}
const cols = Math.max(2, Math.floor(availW / cell.width));
const rows = Math.max(1, Math.floor(availH / cell.height));
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
} catch {
try {
fit.fit();
} catch {
// ignore — fit fails if the container is detached or zero-sized
}
}
};
let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
// Snapshot replay starts at row zero, which can leave Codex at its oldest line.
// Follow it once per socket; later scrolling remains under the operator's control.
let awaitingReplay = true;
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(msg));
};
/** PTY input: raw bytes out as a binary frame. Shared by keystrokes and the
* mobile key bar so both flow through the same path. */
const sendInput = (data: string) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(new TextEncoder().encode(data));
};
const onResize = () => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
};
function connect(): void {
awaitingReplay = true;
ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer";
ws.onopen = () => {
reconnectMs = INITIAL_RECONNECT_MS;
setState("connected", "open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (mounted && focusOnMount) term.focus();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
try {
JSON.parse(ev.data) as ClientControl;
} catch {
// ignore unknown control frames
}
return;
}
if (ev.data instanceof ArrayBuffer) {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), () => {
if (replay) term.scrollToBottom();
});
} else if (ev.data instanceof Blob) {
// Some browsers may default to Blob despite binaryType="arraybuffer".
void ev.data.arrayBuffer().then((b) => {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(b), () => {
if (replay) term.scrollToBottom();
});
});
}
};
ws.onerror = () => {
setState("error", "error");
};
ws.onclose = (ev) => {
// The web proxy relays a daemon rejection (unknown session) as an
// application close code (4000+) carrying a human reason. Show it in the
// pane — both the header and the terminal body — and KEEP the pane up so
// the user can read why, instead of letting it flash and vanish (#39).
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason && !disposed) {
setState(reason, "error");
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
return;
}
// An intentional dispose has already set `disposed`; eviction and user
// close handle their own pane teardown.
if (disposed) return;
if (opts.shouldReconnect?.(sessionId, ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
setState("reconnecting…", "connecting");
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
connect();
}, delay);
return;
}
setState(`closed (${ev.code})`, "closed");
opts.onClosed?.(sessionId);
};
}
connect();
// PTY input: every keystroke goes out as a binary frame. A pending key-bar
// Ctrl modifier (mobile) folds into the next typed character here.
const dataDisp = term.onData((data) => {
sendInput(keybar.consume(data));
});
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
// xterm has no built-in copy-on-select; mirror the selection to the
// clipboard ourselves (best-effort — insecure contexts lack the API).
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize);
// FitAddon only recomputes on the events we call it from — a window resize
// misses container-driven changes (sidebar drag, mobile layout settling,
// orientation, late font load). Observe the body directly so columns always
// match the real rendered width and the terminal fills edge to edge.
const ro = new ResizeObserver(() => tryFit());
ro.observe(body);
// Re-fit once the monospace font is actually loaded — cell width changes when
// the fallback is swapped out, which otherwise leaves stale columns.
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
let disposed = false;
return {
sessionId,
el,
onMount(mountOpts?: { focus?: boolean }) {
mounted = true;
focusOnMount = mountOpts?.focus !== false;
acquireWebgl();
// Defer a tick so the element has its content-pane dimensions before fit.
requestAnimationFrame(() => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus();
});
},
onUnmount() {
mounted = false;
// Parked views hand their GPU context back: the browser's cap is small
// (smaller still on Android) and MAX_PARKED connections outlive the pane.
releaseWebgl();
},
focusTerminal() {
term.focus();
},
// The closure above: the same bytes-out path keystrokes and the mobile key
// bar already take.
sendInput,
updateInfo(next: AttachInfo) {
titleEl.innerHTML = paneTitleHtml(sessionId, next);
titleEl.title = paneTitleAttr(sessionId, next);
},
dispose() {
if (disposed) return;
disposed = true;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ro.disconnect();
window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize);
dataDisp.dispose();
resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl();
try {
ws.close();
} catch {
// ignore
}
term.dispose();
el.remove();
},
};
}
/**
* One key in the mobile key bar.
* - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt).
* - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a
* pending modifier can promote it to the `\x1b[1;<n><final>` form.
* - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its
* CSI parameter, whose modified form is `\x1b[<code>;<n>~`.
* - `char` is a literal character (the symbols the soft keyboard buries behind
* a page switch), folded through a pending modifier like typed input.
* - `seq` is a plain key (Esc, Tab) sent verbatim.
*/
type KeyDef =
| { label: string; mod: Modifier }
| { label: string; cursor: CursorFinal }
| { label: string; tilde: number }
| { label: string; char: string }
| { label: string; seq: string };
type Modifier = "ctrl" | "alt";
/** CSI final bytes: arrows A–D, Home `H`, End `F`. */
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
/**
* The keys the mobile soft keyboard hides, ordered to flow row-major into an
* 8-column / 2-row grid (#7):
*
* Esc Tab Home ↑ End PgUp - /
* Ctrl Alt ← ↓ → PgDn | ~
*
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, the cursor keys as a
* real-keyboard inverted-T in the middle with Home/End filling its top corners,
* then the paging pair and the shell symbols that cost a soft-keyboard page
* switch to reach. Narrower screens scroll the bar sideways rather than
* shrinking the caps below a thumb's width.
*/
const KEY_DEFS: KeyDef[] = [
{ label: "Esc", seq: "\x1b" },
{ label: "Tab", seq: "\t" },
{ label: "Home", cursor: "H" },
{ label: "↑", cursor: "A" },
{ label: "End", cursor: "F" },
{ label: "PgUp", tilde: 5 },
{ label: "-", char: "-" },
{ label: "/", char: "/" },
{ label: "Ctrl", mod: "ctrl" },
{ label: "Alt", mod: "alt" },
{ label: "←", cursor: "D" },
{ label: "↓", cursor: "B" },
{ label: "→", cursor: "C" },
{ label: "PgDn", tilde: 6 },
{ label: "|", char: "|" },
{ label: "~", char: "~" },
];
/**
* Fold a single character into its control code: letters and `@[\]^_` map to
* 0x00–0x1f, space → NUL, `?` → DEL. Anything else is returned unchanged.
*/
interface KeyBar {
readonly el: HTMLElement;
/** Fold pending modifiers into typed input (soft keyboard), consuming them. */
consume(data: string): string;
}
/**
* Build the Termux-style special-key bar. Hidden by default; CSS reveals it on
* coarse-pointer (touch) devices. `send` delivers bytes to the PTY.
*
* Ctrl and Alt are one-shot sticky modifiers: arm them and the very next key —
* tapped here or typed on the soft keyboard — carries the modifier, then they
* disarm. Taps `preventDefault` on pointerdown so the terminal keeps focus and
* the keyboard stays open.
*/
function buildKeyBar(send: (data: string) => void): KeyBar {
const el = document.createElement("div");
el.className = "attach-keybar";
el.setAttribute("role", "toolbar");
el.setAttribute("aria-label", "Special keys");
const armed: Record<Modifier, boolean> = { ctrl: false, alt: false };
const modBtns: Partial<Record<Modifier, HTMLButtonElement>> = {};
const setMod = (mod: Modifier, on: boolean) => {
armed[mod] = on;
modBtns[mod]?.setAttribute("aria-pressed", String(on));
};
const clearMods = () => {
setMod("ctrl", false);
setMod("alt", false);
};
for (const def of KEY_DEFS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "keycap";
btn.textContent = def.label;
if ("mod" in def) {
btn.classList.add("keycap-mod");
btn.setAttribute("aria-pressed", "false");
btn.setAttribute("aria-label", `${def.label} modifier`);
modBtns[def.mod] = btn;
btn.addEventListener("click", () => setMod(def.mod, !armed[def.mod]));
} else if ("cursor" in def) {
btn.addEventListener("click", () => {
send(cursorSeq(def.cursor, armed.ctrl, armed.alt));
clearMods();
});
} else if ("tilde" in def) {
btn.addEventListener("click", () => {
send(tildeSeq(def.tilde, armed.ctrl, armed.alt));
clearMods();
});
} else if ("char" in def) {
btn.addEventListener("click", () => {
send(applyModsToChar(def.char, armed.ctrl, armed.alt));
clearMods();
});
} else {
btn.addEventListener("click", () => {
send(def.seq);
clearMods();
});
}
// Keep the terminal focused (soft keyboard up) — don't let the tap steal it.
// preventDefault suppresses the native `:active` style, so flash an explicit
// press animation here for tactile feedback.
btn.addEventListener("pointerdown", (e) => {
e.preventDefault();
btn.classList.remove("is-pressed");
void btn.offsetWidth; // restart the animation if re-tapped mid-flash
btn.classList.add("is-pressed");
});
btn.addEventListener("animationend", () => btn.classList.remove("is-pressed"));
el.appendChild(btn);
}
return {
el,
consume(data: string): string {
if ((armed.ctrl || armed.alt) && data.length === 1) {
const out = applyModsToChar(data, armed.ctrl, armed.alt);
clearMods();
return out;
}
return data;
},
};
}
-139
View File
@@ -1,139 +0,0 @@
// @vitest-environment happy-dom
//
// The sidebar's half of the command bar's action page (#151): the bar picks
// kill or rename and raises the flow the card already has, so what the page
// runs is what the ✕ button and the `r` key run.
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
});
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
afterEach(() => {
screen.queryByRole("dialog")?.remove();
mounted?.dispose();
mounted = null;
document.body.replaceChildren();
});
function session(overrides: Partial<Session> = {}): Session {
return {
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: { name: "amber-falcon" },
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
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(s: Session): HTMLElement {
const handle = renderSidebar(storeWith([s]));
mounted = handle;
document.body.appendChild(handle.el);
return handle.el;
}
function raise(name: string, s: Session): void {
document.dispatchEvent(new CustomEvent(name, { detail: s }));
}
/** Let the kill chain's awaits (fetch, its json body) settle. */
async function flush(): Promise<void> {
for (let i = 0; i < 8; i++) await Promise.resolve();
}
describe("kill from the action page", () => {
it("goes through the same confirmation the sidebar raises (#160)", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
const s = session();
mount(s);
raise(KILL_SESSION_EVENT, s);
await flush();
expect(screen.getByRole("dialog", { name: "Kill amber-falcon" })).toBeTruthy();
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
await flush();
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
window.fetch = ((url: string) => {
if (String(url).includes("force=true")) {
return Promise.resolve(new Response(null, { status: 204 }));
}
return Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "kill_guarded",
message: "worktree has unsaved work",
unsaved_work: { untracked_files: 1 },
},
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
const s = session({ blocked_work: { untracked_files: 1 } });
mount(s);
raise(KILL_SESSION_EVENT, s);
await flush();
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
await flush();
const dialog = screen.getByRole("dialog", { name: "Kill amber-falcon" });
expect(dialog.textContent).toContain("1 untracked file");
});
});
describe("rename from the action page", () => {
it("expands the folder hiding the card, since the prompt lives in the card", () => {
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
const s = session();
const el = mount(s);
fireEvent.click(within(el).getByRole("button", { name: /maestro/ }));
expect(within(el).queryByRole("link", { name: /amber-falcon/ })).toBeNull();
raise(RENAME_SESSION_EVENT, s);
expect(within(el).getByRole("textbox", { name: "Rename amber-falcon" })).toHaveValue(
"amber-falcon",
);
});
it("raises the card's own prompt, pre-filled with the session's name", () => {
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
const s = session();
const el = mount(s);
raise(RENAME_SESSION_EVENT, s);
expect(within(el).getByRole("textbox", { name: "Rename amber-falcon" })).toHaveValue(
"amber-falcon",
);
});
});
-460
View File
@@ -1,460 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { InTheWay, Session } from "./types.js";
// happy-dom runs without a backing store; renderSidebar reads the persisted
// sidebar width on mount.
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
// No daemon here; renderSidebar treats a failed info fetch as "absolute
// paths are a fine fallback".
window.fetch = () => Promise.reject(new Error("offline"));
});
afterEach(() => {
document.body.replaceChildren();
});
function session(overrides: Partial<Session>): Session {
return {
id: "11111111-2222-3333-4444-555555555555",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
function storeWith(sessions: Session[]): CatalogStore {
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
return {
snapshot: () => state,
// Like the real store, deliver an immediate snapshot so the sidebar
// renders its rows during construction.
subscribe: (fn: CatalogListener) => {
fn(state);
return () => {};
},
} as unknown as CatalogStore;
}
/** The session's card, scoped by id: rows carry no other stable, queryable
* handle (names/aria-labels routinely collide across the fixtures below), so
* this stays a raw DOM lookup. Every assertion made *within* the row it
* returns goes through `within(row)`. */
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
}
/** Mount a sidebar and attach it to the document, the way the shell does —
* screen/within queries and `:focus` both need a connected tree. */
function mount(store: CatalogStore): HTMLElement {
const el = renderSidebar(store).el;
document.body.appendChild(el);
return el;
}
describe("blocked-work indicator (#83)", () => {
it("marks the row of a blocked session in diff notation", () => {
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000001",
blocked_work: {
uncommitted_files: 3,
unpushed_commits: 2,
insertions: 124,
deletions: 18,
},
});
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
const badge = within(row).getByTitle(/Kill will be refused unless forced/);
expect(badge.textContent).toBe("+124-18↑2");
// The spelled-out breakdown survives in the tooltip.
expect(badge.getAttribute("title")).toContain("3 uncommitted files, 2 unpushed commits");
});
it("falls back to the file count when tracked changes carry no lines", () => {
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-00000000001a",
blocked_work: { uncommitted_files: 2, untracked_files: 1 },
});
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
const badge = within(row).getByTitle(/Kill will be refused unless forced/);
expect(badge.textContent).toBe("~2?1");
});
it("does not mark clear or unprobed sessions", () => {
const clear = session({
id: "aaaaaaaa-0000-0000-0000-000000000002",
blocked_work: {} as InTheWay,
});
const unprobed = session({
id: "aaaaaaaa-0000-0000-0000-000000000003",
});
const el = mount(storeWith([clear, unprobed]));
expect(
within(rowFor(el, clear.id)!).queryByTitle(/Kill will be refused/),
).toBeNull();
expect(
within(rowFor(el, unprobed.id)!).queryByTitle(/Kill will be refused/),
).toBeNull();
});
it("confirms before an unguarded kill (#160)", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
const el = mount(storeWith([plain]));
const row = rowFor(el, plain.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill claude" }));
const dialog = screen.getByRole("dialog", { name: "Kill claude" });
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
fireEvent.click(within(dialog).getByRole("button", { name: "Kill" }));
await waitFor(() => {
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
it("kills nothing when the confirmation is cancelled (#160)", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" });
const el = mount(storeWith([plain]));
const row = rowFor(el, plain.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill claude" }));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
});
it("names the blockers in the confirmation, before the daemon refuses (#160)", async () => {
window.fetch = (() => Promise.reject(new Error("never called"))) as typeof fetch;
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000008",
blocked_work: { untracked_files: 1 },
});
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill claude" }));
const dialog = screen.getByRole("dialog", { name: "Kill claude" });
expect(dialog.textContent).toContain("1 untracked file");
expect(dialog.textContent).toContain("refused unless it is forced");
});
it("offers the override when the daemon refuses a guarded kill", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
if (url.includes("force=true")) return Promise.resolve(new Response(null, { status: 204 }));
return Promise.resolve(
new Response(
JSON.stringify({
error: {
code: "kill_guarded",
message: "worktree has unsaved work",
unsaved_work: { untracked_files: 1 },
},
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000004",
command: "bash",
blocked_work: { untracked_files: 1 },
});
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill bash" }));
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
// The guarded refusal replaces the confirm dialog with the force prompt;
// wait for the swap rather than the (name-identical) dialog role alone.
const forceBtn = await screen.findByRole("button", { name: "Force kill" });
const dialog = screen.getByRole("dialog");
expect(dialog.textContent).toContain("1 untracked file");
fireEvent.click(forceBtn);
await waitFor(() => {
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
it("closes the override prompt without killing on cancel", async () => {
const calls: string[] = [];
window.fetch = ((url: string, init?: RequestInit) => {
calls.push(`${init?.method ?? "GET"} ${url}`);
return Promise.resolve(
new Response(
JSON.stringify({
error: { code: "kill_guarded", message: "worktree has unsaved work" },
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000006",
blocked_work: { unpushed_commits: 2 },
});
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill claude" }));
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
// Same reasoning as above: wait for the force prompt's own button before
// trusting "the" Cancel button is the one that belongs to it.
await screen.findByRole("button", { name: "Force kill" });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
});
});
describe("agent-reported detail (#112)", () => {
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
it("renders the reported detail right after the duration on line 2", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000020",
status: "Dead",
activity: "Error",
activity_since: ago(90_000),
activity_detail: "cargo build failed",
});
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const line2 = row.querySelector<HTMLElement>(".card-line2")!;
expect(within(line2).getByText("cargo build failed")).toHaveClass("activity-detail");
const order = [...line2.children].map((c) => c.className);
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
expect(order.indexOf("activity-detail")).toBeLessThan(
order.findIndex((c) => c.startsWith("card-flag")),
);
});
it("omits the detail when no report is in force", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000021",
activity: "Active",
activity_since: ago(90_000),
});
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
// No text is rendered for a report that isn't in force, so there is
// nothing an accessible query can key off — this stays a raw DOM check.
expect(row.querySelector(".activity-detail")).toBeNull();
});
});
describe("session cards (#107)", () => {
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const line1 = row.querySelector<HTMLElement>(".card-line1")!;
expect(line1).not.toBeNull();
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
expect(within(line1).getByText("claude")).toHaveClass("leaf-name");
expect(line1.querySelector(".leaf-actions")).toBe(line1.lastElementChild);
});
it("always renders line 2 for a freshly spawned session", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000011",
activity: "Active",
activity_since: ago(5_000),
});
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const line2 = row.querySelector<HTMLElement>(".card-line2");
expect(line2).not.toBeNull();
expect(within(row).getByRole("img", { name: "Active" })).toHaveClass("activity-rail");
expect(line2!.querySelector(".activity-duration")).toBeNull();
expect(within(row).getByTitle("claude")).toHaveClass("leaf-cmd");
});
it("shows dot + floored duration once the state has been held past 30s", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000012",
activity: "Active",
activity_since: ago(4 * 60_000 + 30_000),
});
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const line2 = row.querySelector<HTMLElement>(".card-line2")!;
expect(line2.dataset.activity).toBe("active");
expect(within(row).getByRole("img", { name: "Active" })).toHaveClass("activity-rail");
expect(within(row).getByText("4m")).toHaveClass("activity-duration");
});
it("dims a name the daemon generated, until it settles (#131)", () => {
const generated = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a1",
metadata: { name: "amber-falcon", name_source: "Generated" },
});
const haiku = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a2",
metadata: { name: "sidebar-rail", name_source: "Haiku" },
});
const chosen = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a3",
metadata: { name: "my-session", name_source: "User" },
});
// Named before `name_source` existed: settled, never dimmed.
const legacy = session({
id: "aaaaaaaa-0000-0000-0000-0000000000a4",
metadata: { name: "older-session" },
});
const el = mount(storeWith([generated, haiku, chosen, legacy]));
const nameEl = (s: Session, text: string) => within(rowFor(el, s.id)!).getByText(text);
expect(nameEl(generated, "amber-falcon")).toHaveClass("is-provisional");
expect(nameEl(haiku, "sidebar-rail")).not.toHaveClass("is-provisional");
expect(nameEl(chosen, "my-session")).not.toHaveClass("is-provisional");
expect(nameEl(legacy, "older-session")).not.toHaveClass("is-provisional");
});
it("renders the command as its tool icon when the tool is known", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001b",
command: "claude",
metadata: { name: "claude" },
});
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const cmd = within(row).getByTitle("claude");
expect(cmd).toHaveClass("leaf-cmd");
expect(cmd.querySelector<HTMLElement>(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
// The icon said it all: no text left over.
expect(cmd.querySelector(".leaf-cmd-rest")).toBeNull();
});
it("drops the command's arguments, keeping them in the tooltip", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001c",
command: "claude --dangerously-skip-permissions",
});
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const cmd = within(row).getByTitle("claude --dangerously-skip-permissions");
expect(cmd).toHaveClass("leaf-cmd");
expect(cmd.querySelector<HTMLElement>(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd.querySelector(".leaf-cmd-rest")).toBeNull();
});
it("keeps an unknown command as plain text", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001d",
command: "hx src/main.rs",
});
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const cmd = within(row).getByTitle("hx src/main.rs");
expect(cmd.querySelector(".tool-icon")).toBeNull();
expect(within(cmd).getByText("hx")).toHaveClass("leaf-cmd-rest");
});
it("maps each activity state to its contract slug", () => {
const mk = (id: string, activity: string) =>
session({
id,
activity: activity as Session["activity"],
status: "Running",
activity_since: ago(90_000),
});
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
const el = mount(storeWith([idle, wait, error]));
// `data-activity` is a styling hook with no accessible surface of its
// own (the rail's role/name cover the state word, not the slug), so this
// stays a plain attribute assertion.
const activityOf = (s: Session) => rowFor(el, s.id)!.querySelector(".card-line2");
expect(activityOf(idle)).toHaveAttribute("data-activity", "idle");
expect(activityOf(wait)).toHaveAttribute("data-activity", "awaiting-input");
expect(activityOf(error)).toHaveAttribute("data-activity", "error");
});
it("treats an unknown activity variant as idle instead of failing to render", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000016",
activity: "Teleported" as Session["activity"],
activity_since: ago(120_000),
});
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
expect(row.querySelector(".card-line2")).toHaveAttribute("data-activity", "idle");
expect(within(row).getByText("2m")).toHaveClass("activity-duration");
});
it("moves blocked work onto line 2 as diff notation", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000017",
blocked_work: {
uncommitted_files: 3,
unpushed_commits: 2,
insertions: 124,
deletions: 18,
},
});
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
expect(within(row).getByText("+124")).toBeTruthy();
expect(within(row).getByText("-18")).toBeTruthy();
// Line 1 carries no badges.
expect(row.querySelector(".leaf-badge")).toBeNull();
});
it("shows exited on line 2 for dead sessions", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000018",
status: "Dead",
});
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
expect(within(row).getByText("exited")).toBeTruthy();
});
it("carries the state word only in tooltips and aria labels, never as text", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000019",
command: "claude",
metadata: { name: "worker" },
activity: "AwaitingInput",
activity_since: ago(90_000),
});
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const rail = within(row).getByRole("img", { name: "Awaiting input" });
expect(rail).toHaveAttribute("title", "Awaiting input");
expect(row.textContent).not.toMatch(/awaiting.?input/i);
});
it("keeps the attach navigation and depth indentation", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
const el = mount(storeWith([s]));
const row = rowFor(el, s.id) as HTMLAnchorElement;
expect(row).toHaveAttribute("href", "#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
});
});
-872
View File
@@ -1,872 +0,0 @@
import { escapeHtml } from "./html.js";
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
import { openContextMenu } from "./contextMenu.js";
import { toggleHelp } from "./help.js";
import { CommandSet, defineCommands } from "./commands.js";
import { CatalogStore } from "./events.js";
import type { InTheWay, Session } from "./types.js";
import {
abbreviateCwd,
abbreviateFolderName,
blockerStats,
customSessionName,
formatInTheWay,
MAX_SESSION_NAME_LEN,
normalizeSessionName,
sessionBlockers,
isProvisionalName,
sessionName,
sessionTreePath,
} from "./types.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
META_LINE_MIN_HOLD_MS,
} from "./activity.js";
import type { TreeNode } from "./tree.js";
import { buildTree } from "./tree.js";
import { createPaletteSelect, createThemeToggle } from "./theme.js";
import type { DaemonLink } from "./daemonLink.js";
import { createStatusIndicator } from "./statusIndicator.js";
/** Ask the shell to open the spawn modal. Handled in main.ts, where the
* multi-view controller lives; dispatched from every Spawn affordance. */
export const OPEN_SPAWN_EVENT = "catalog-open-spawn";
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. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
/** Ask the sidebar to kill a session — the command bar's action page picking
* `kill` (#151). Detail is the session. Handled here, so the kill goes
* through the same confirm-and-blockers flow the card's ✕ does
* (`docs/WORKTREES.md`); the bar never reimplements it. */
export const KILL_SESSION_EVENT = "catalog-kill-session";
/** Ask the sidebar to rename a session — the command bar's action page picking
* `rename` (#151). Detail is the session. The prompt is the card's own inline
* field, pre-filled with the session's name (`docs/SIDEBAR.md`). */
export const RENAME_SESSION_EVENT = "catalog-rename-session";
/** A session row was clicked (issue #133). The row's `href` drives normal
* hash navigation, but a split/drag can move focus without ever touching
* `location.hash` — so a click back onto a session whose hash still
* matches fires no `hashchange` and the app never hears about it. This
* event is the click-driven path that doesn't depend on the hash actually
* changing. */
export const SESSION_SELECT_EVENT = "catalog-session-select";
/** The rows a keyboard walk stops on, in visual (document) order. Expanded
* folder headers are skipped: a directory is not something to focus, and the
* cards under it are one row away anyway. A *collapsed* folder still counts —
* its header is the only row it has, so skipping it would leave the folder
* unreachable. Same rule as the TUI sidebar. */
const NAV_SELECTOR = '.tree-leaf, .tree-folder-header[aria-expanded="false"]';
const collapsed = new Set<string>();
/** Session whose card is currently being renamed inline (#125). The tree
* re-renders on every catalog delta, and an activity update arriving mid-edit
* would otherwise replace the field the user is typing into. */
let renamingId: string | null = null;
// docs/SIDEBAR.md: the state word is never rendered — the rail's colour
// carries it. It survives only in tooltips/aria labels so meaning is not
// colour-only.
const STATE_WORDS: Record<string, string> = {
active: "Active",
idle: "Idle",
"awaiting-input": "Awaiting input",
error: "Error",
};
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
* full height. The row's own padding (`renderNode`) shifts outward with
* nesting depth, carrying the rail with it. */
function activityRailMarkup(slug: string, stateWord: string): string {
return `<span class="activity-rail" data-activity="${slug}" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
}
function metaLine(s: Session, now: number): string {
const dead = s.status === "Dead";
const blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle");
const held = activityHeldMs(s.activity_since, now);
const duration =
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
const fragments = [
duration ? `<span class="activity-duration">${duration}</span>` : "",
s.activity_detail
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
: "",
commandFragment(s.command),
blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "",
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
/** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2`
* — with the spelled-out breakdown kept in the tooltip. */
function blockedFragment(blockers: InTheWay): string {
const stats = blockerStats(blockers)
.map((st) => `<span class="blocked-stat" data-stat="${st.kind}">${escapeHtml(st.text)}</span>`)
.join("");
const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`;
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
}
/** docs/SIDEBAR.md: the card names the program only — arguments never reach
* the card. The program becomes its tool icon when we have a mark for it, and
* stays as its bare name when we do not. The full command line, arguments and
* all, stays in the tooltip either way. */
function commandFragment(command: string): string {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? "" : program;
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
}
function renderNode(node: TreeNode, depth: number): HTMLElement {
if (node.kind === "folder") {
const wrap = document.createElement("div");
wrap.className = "tree-folder";
if (node.name) {
const header = document.createElement("button");
header.type = "button";
header.className = "tree-folder-header";
header.style.paddingLeft = `${depth * 16}px`;
const isCollapsed = collapsed.has(node.path);
header.setAttribute("aria-expanded", String(!isCollapsed));
header.dataset.navKey = `folder:${node.path}`;
// Single down-chevron; CSS rotates it -90° (→ right) when collapsed, so the
// glyph and the rotation never fight each other.
// A deep path (`~/git.naps.pt/naps62/maestro`) collapses its leading
// segments to initials (`~/g/n/maestro`) so the dir name stays readable in
// a narrow sidebar; the full path lives in the tooltip.
header.innerHTML = `<span class="chevron">▾</span><span class="folder-name" title="${escapeHtml(node.name)}">${abbreviateFolderName(node.name)}</span>`;
header.addEventListener("click", () => {
if (collapsed.has(node.path)) collapsed.delete(node.path);
else collapsed.add(node.path);
// re-render handled by caller observing the store; trigger a refresh:
document.dispatchEvent(new CustomEvent("catalog-rerender"));
});
wrap.appendChild(header);
if (isCollapsed) return wrap;
}
for (const child of node.children) {
wrap.appendChild(renderNode(child, node.name ? depth + 1 : depth));
}
return wrap;
}
// The row outlives the snapshot it was built from: a refresh that changes
// nothing structural patches it in place rather than replacing it, so the
// handlers below have to read the current session, not the one captured here.
let cur = node.session;
const s = node.session;
const now = Date.now();
const row = document.createElement("a");
row.className = "tree-leaf session-card";
row.href = `#/attach/${encodeURIComponent(s.id)}`;
row.style.paddingLeft = `${depth * 16 + 8}px`;
row.dataset.sessionId = s.id;
row.dataset.navKey = `session:${s.id}`;
const running = s.status === "Running";
row.dataset.status = running ? "running" : "dead";
const name = sessionName(s);
const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = `
${activityRailMarkup(slug, stateWord)}
<span class="card-line1">
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
</span>
</span>
${metaLine(s, now)}
`;
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
similarBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
});
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
killBtn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
requestKill(cur, sessionName(cur));
});
row.addEventListener("click", () => {
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: cur.id }));
});
row.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
openContextMenu(ev.clientX, ev.clientY, [
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
{
label: "Spawn similar",
onSelect: () =>
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
},
{
label: "Kill session",
danger: true,
onSelect: () => requestKill(cur, sessionName(cur)),
},
]);
});
leafPatches.set(row, (next) => {
cur = next;
const nextSlug = activityColorSlug(next.activity ?? "Idle");
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
row.setAttribute(
"aria-label",
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
);
const rail = row.querySelector(".activity-rail");
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
const meta = row.querySelector(".card-line2");
if (meta) meta.outerHTML = metaLine(next, Date.now());
});
return row;
}
/** Refresh a standing card from a newer snapshot of its session, keeping the
* element (and so the pointer's `:hover`) exactly where it is. Registered by
* `renderNode`; only the bits `treeSignature` leaves out are rewritten. */
const leafPatches = new WeakMap<HTMLElement, (s: Session) => void>();
/**
* Everything about the tree that a patch cannot fix up.
*
* The daemon re-sends a session on every activity, foreground or cwd shift —
* a couple of times a minute per live session — and rebuilding the sidebar for
* each one drops `:hover` from the row under the pointer. Refreshes whose
* signature is unchanged are patched into the standing rows instead; anything
* that moves, renames, appears or dies changes the signature and rebuilds.
*/
function treeSignature(node: TreeNode): string {
if (node.kind === "folder") {
const head = `F:${node.name}:${node.path}:${collapsed.has(node.path)}`;
if (node.name && collapsed.has(node.path)) return head;
return `${head}(${node.children.map(treeSignature).join(",")})`;
}
const s = node.session;
return `L:${s.id}:${sessionName(s)}:${s.status}:${isProvisionalName(s)}`;
}
/**
* Kill behind a confirmation (#160): a kill is destructive and unattended
* agents live in these sessions, so the web asks first, the way the TUI always
* has. The daemon decides the rest — it refuses a kill over a worktree holding
* unsaved work, and that refusal needs a prompt the confirmation cannot give,
* a forced kill. Anything else surfaces in the dialog that asked.
*/
function requestKill(s: Session, name: string): void {
openKillConfirmDialog({
name,
blockers: sessionBlockers(s),
onKill: async () => {
try {
await killSession(s.id);
} catch (e) {
if (e instanceof ApiRequestError && e.code === "kill_guarded") {
openForceKillDialog({
name,
blockers: e.unsavedWork ?? sessionBlockers(s),
reason: e.detail,
onForce: () => killSession(s.id, true),
});
return;
}
throw e;
}
},
});
}
/**
* Swap the card's name for a text field (#125). Enter commits, Escape and
* blur cancel. An emptied field clears the name, so the card falls back to
* the command — the same rule the spawn form's blank name field follows.
*/
function beginRename(row: HTMLElement, s: Session): void {
const nameEl = row.querySelector<HTMLElement>(".leaf-name");
if (!nameEl) return;
const input = document.createElement("input");
input.type = "text";
input.className = "leaf-name leaf-rename";
input.value = customSessionName(s) ?? "";
input.maxLength = MAX_SESSION_NAME_LEN;
input.setAttribute("aria-label", `Rename ${sessionName(s)}`);
// The card is an <a>, and the tree starts a drag on pointerdown: without
// these, clicking into the field navigates away or begins dragging the row.
for (const type of ["click", "pointerdown", "dblclick"]) {
input.addEventListener(type, (ev) => {
ev.preventDefault();
ev.stopPropagation();
input.focus();
});
}
renamingId = s.id;
let settled = false;
const finish = () => {
if (settled) return false;
settled = true;
renamingId = null;
document.dispatchEvent(new CustomEvent("catalog-rerender"));
return true;
};
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
// Normalised here so what is sent is what the daemon will store.
const name = normalizeSessionName(input.value);
if (!finish()) return;
void renameSession(s.id, name).catch((e: Error) => {
alert(`Failed to rename: ${e.message}`);
});
} else if (ev.key === "Escape") {
ev.preventDefault();
finish();
}
});
input.addEventListener("blur", () => finish());
nameEl.replaceWith(input);
input.focus();
input.select();
}
// 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";
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">+</div>
<div class="empty-title">No sessions yet</div>
<p class="empty-hint">Start your first session — it'll show up here.</p>
<div class="empty-actions"></div>`;
const spawn = document.createElement("button");
spawn.type = "button";
spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session";
spawn.addEventListener("click", onSpawn);
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
return el;
}
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
* pane (quiet), and which one is focused (loud). */
export interface SidebarHighlights {
open: Set<string>;
focused: string | null;
}
/** Live handle to the persistent sidebar (built once, survives navigation). */
export interface SidebarHandle {
/** The `<aside>` element to mount into the shell. */
el: HTMLElement;
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
* Back-compat shim over {@link setHighlights}. */
setActive(id: string | null): void;
/** Apply the full open/focused highlight state. */
setHighlights(h: SidebarHighlights): void;
/** Mark the row the content pane is previewing (docs/PREVIEW.md), or clear it
* with `null`. A preview is a view, not state, so this only paints. */
setPreview(sessionId: string | null): void;
/** Hand the keyboard to the session list, highlighting a row to walk from. */
focusList(): void;
/** Whether the session list currently holds DOM focus. */
hasListFocus(): boolean;
/** Tear down the store subscription + listeners. */
dispose(): void;
}
/**
* Build the persistent session sidebar: brand, search, spawn, theme toggle, a
* live session status line, and the folder-grouped session tree. Unlike
* the old full-screen catalog this is mounted once by the shell and stays put
* while the content pane swaps between sessions — so the store subscription and
* scroll position survive navigation. The active session is highlighted.
*/
export interface SidebarOptions {
/** Pointerdown on a session row — the multi-view controller decides drag vs
* tap (issue #32). Tap falls through to the row's normal click (open). */
onLeafDrag?: (sessionId: string, e: PointerEvent) => void;
/** Daemon link behind the footer's status dot. Omitted in tests and in the
* design kit, which render the sidebar without a daemon. */
link?: DaemonLink;
/** The highlight moved onto a session, or off every session (`null`): the
* shell shows it in the content pane without opening it (docs/PREVIEW.md).
* `source` is there so a mouse sweep can be debounced differently from a
* keyboard step. */
onPreview?: (sessionId: string | null) => void;
}
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
const el = document.createElement("aside");
el.className = "sidebar";
// The daemon's home dir abbreviates cwds to `~/…`. Fetched once; the tree
// 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 = `
<input
type="search"
class="search"
readonly
placeholder="Search sessions…"
aria-label="Search sessions"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
`;
el.appendChild(searchHeader);
const treeHost = document.createElement("div");
treeHost.className = "tree";
el.appendChild(treeHost);
// App-level actions (help, theme, spawn) live pinned to the sidebar's foot
// rather than the sidebar's own scrolling content, so they stay reachable
// regardless of tree scroll position.
const footer = document.createElement("div");
footer.className = "sidebar-footer";
const helpBtn = document.createElement("button");
helpBtn.type = "button";
helpBtn.className = "btn btn-ghost btn-icon help-btn";
helpBtn.textContent = "?";
helpBtn.title = "Keyboard shortcuts (?)";
helpBtn.setAttribute("aria-label", "Keyboard shortcuts");
helpBtn.addEventListener("click", () => toggleHelp());
footer.appendChild(helpBtn);
footer.appendChild(createThemeToggle());
footer.appendChild(createPaletteSelect());
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
if (import.meta.env.DEV) {
const kit = document.createElement("a");
kit.className = "btn btn-ghost";
kit.href = "?kit";
kit.textContent = "kit";
kit.title = "Open the design kit (DEV)";
footer.appendChild(kit);
}
// Daemon status sits beside the spawn button: colour for the link state,
// hover for the release the daemon is running.
const status = opts.link ? createStatusIndicator(opts.link) : null;
if (status) footer.appendChild(status.el);
const spawnBtn = document.createElement("button");
spawnBtn.type = "button";
spawnBtn.className = "btn btn-primary spawn-btn";
spawnBtn.textContent = "+ Spawn";
// The keyboard pair sits right here in the tooltip (#67).
spawnBtn.title = "Spawn form: n — fast-forward spawn: N";
spawnBtn.addEventListener("click", () => requestOpenSpawn());
footer.appendChild(spawnBtn);
el.appendChild(footer);
// Drag a session row into the canvas (issue #32). Delegated so it survives
// tree re-renders; the controller handles drag-vs-tap (tap still navigates).
treeHost.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || !opts.onLeafDrag) return;
const leaf = (e.target as HTMLElement).closest<HTMLElement>(".tree-leaf");
const id = leaf?.dataset.sessionId;
if (id) opts.onLeafDrag(id, e);
});
// The rows are <a> elements, so the browser's native link drag-and-drop would
// otherwise hijack pointermove and our live drop-zone preview wouldn't show
// until release. Suppress it so the pointer-based drag runs from the start.
treeHost.addEventListener("dragstart", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) e.preventDefault();
});
// Drag-to-resize handle on the sidebar's right edge. Persists the chosen
// width as a :root CSS var the shell grid reads; clamped to a sane range.
const SIDEBAR_W_KEY = "maestro-sidebar-w";
const storedW = localStorage.getItem(SIDEBAR_W_KEY);
if (storedW) document.documentElement.style.setProperty("--sidebar-w", storedW);
const handle = document.createElement("div");
handle.className = "sidebar-resize";
handle.setAttribute("aria-hidden", "true");
el.appendChild(handle);
let dragStartX = 0;
let dragStartW = 0;
let dragging = false;
handle.addEventListener("pointerdown", (e) => {
dragging = true;
dragStartX = e.clientX;
dragStartW = el.getBoundingClientRect().width;
handle.setPointerCapture(e.pointerId);
document.body.style.userSelect = "none";
e.preventDefault();
});
handle.addEventListener("pointermove", (e) => {
if (!dragging) return;
const w = Math.max(200, Math.min(560, dragStartW + (e.clientX - dragStartX)));
document.documentElement.style.setProperty("--sidebar-w", `${w}px`);
});
const endDrag = (e: PointerEvent) => {
if (!dragging) return;
dragging = false;
try {
handle.releasePointerCapture(e.pointerId);
} catch {
// pointer may already be released
}
document.body.style.userSelect = "";
const cur = document.documentElement.style.getPropertyValue("--sidebar-w");
if (cur) localStorage.setItem(SIDEBAR_W_KEY, cur);
};
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag);
// Touch entry (#157): the field is a button that looks like a field. It
// never holds a query — focusing or tapping it hands the keyboard straight
// to the bar's own input, which is also why it blurs first: leaving the
// focus here would make the bar impossible to close without reopening it.
const search = searchHeader.querySelector(".search") as HTMLInputElement;
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 };
// The previewed row is painted alongside the open/focused tiers so a
// re-render can't drop it, but it is never part of `highlights`: a preview
// is a view, and nothing about it is durable (docs/PREVIEW.md).
let previewing: string | null = null;
const applyHighlights = () => {
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const id = n.dataset.sessionId;
if (!id) continue;
const focused = highlights.focused === id;
const open = highlights.open.has(id);
n.classList.toggle("is-active", focused);
n.classList.toggle("is-open", open && !focused);
n.classList.toggle("is-previewing", previewing === id);
}
};
// ---- keyboard navigation ----
// The walk reads the rendered DOM rather than a parallel model, so it can
// never disagree with what is on screen. Rows carry a `data-nav-key`
// (`session:<id>` / `folder:<path>`) that survives a re-render.
let navKey: string | null = null;
const navRows = (): HTMLElement[] => [
...treeHost.querySelectorAll<HTMLElement>(NAV_SELECTOR),
];
const rowFor = (key: string | null): HTMLElement | undefined =>
key === null ? undefined : navRows().find((r) => r.dataset.navKey === key);
const applyNav = () => {
const rows = navRows();
// A row that went away (session killed, folder collapsed) drops the
// highlight rather than leaving it pointing at nothing.
if (navKey !== null && !rows.some((r) => r.dataset.navKey === navKey)) navKey = null;
for (const r of rows) r.classList.toggle("is-highlighted", r.dataset.navKey === navKey);
};
const sessionOf = (key: string | null): string | null =>
key?.startsWith("session:") ? key.slice("session:".length) : null;
const moveNav = (delta: number) => {
const rows = navRows();
if (rows.length === 0) return;
const cur = rows.findIndex((r) => r.dataset.navKey === navKey);
// Nothing highlighted: start one off the end we are walking from, so the
// first step lands on the first (or last) row.
const from = cur === -1 ? (delta > 0 ? -1 : 0) : cur;
const next = rows[(((from + delta) % rows.length) + rows.length) % rows.length];
navKey = next.dataset.navKey ?? null;
applyNav();
next.scrollIntoView({ block: "nearest" });
opts.onPreview?.(sessionOf(navKey));
};
/** Enter: open the highlighted session, or expand the highlighted folder. */
const activateNav = () => {
const row = rowFor(navKey);
if (!row) return;
if (row.classList.contains("tree-leaf")) {
row.click();
return;
}
// Expanding replaces the folder's header in the walk with the rows it was
// hiding, so land on the first of them instead of losing the highlight.
const key = navKey;
row.click();
const header = [...treeHost.querySelectorAll<HTMLElement>(".tree-folder-header")].find(
(h) => h.dataset.navKey === key,
);
const revealed = header?.parentElement
? [...header.parentElement.querySelectorAll<HTMLElement>(NAV_SELECTOR)][0]
: undefined;
navKey = revealed?.dataset.navKey ?? null;
applyNav();
};
// Pointer hover previews only while Ctrl is held — plain hover was too
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
// unconditionally via applyNav.
let hovered: string | null = null;
const setHovered = (id: string | null) => {
if (hovered === id) return;
hovered = id;
opts.onPreview?.(id);
};
treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch" || !e.ctrlKey) return;
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
setHovered(row?.dataset.sessionId ?? null);
});
treeHost.addEventListener("pointerleave", (e) => {
if (e.pointerType === "touch") return;
setHovered(null);
});
// Focusable as a unit: the walk moves a highlight, not DOM focus, so the
// keyboard stays here while j/k runs.
treeHost.tabIndex = -1;
// The keyboard walk's preview belongs to the list having the keyboard: once
// it doesn't, the content pane goes back to what the operator actually opened.
treeHost.addEventListener("blur", () => opts.onPreview?.(null));
/** The session behind the highlighted row, if the highlight is on a card. */
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
const row = rowFor(navKey);
const id = row?.dataset.sessionId;
if (!row || !id) return undefined;
const session = store.snapshot().sessions.get(id);
return session ? { row, session } : undefined;
};
/** `c`: collapse or expand the highlighted folder, the TUI's
* `ToggleSidebarGroup`. Clicking the header is what toggles it; on a card
* the key does nothing rather than toggling some other folder. */
const toggleHighlightedFolder = () => {
const row = rowFor(navKey);
if (!row || !row.classList.contains("tree-folder-header")) return;
row.click();
applyNav();
};
// The session list's half of the command registry (#148). One set per
// sidebar, so a second sidebar (tests, hot reload) drives its own rows; the
// labels and chords still come from the one registry.
const listKeys = new CommandSet(
defineCommands({
"list.next": () => moveNav(1),
"list.prev": () => moveNav(-1),
"list.activate": () => activateNav(),
"list.toggleFolder": () => toggleHighlightedFolder(),
"list.rename": () => {
const hit = highlightedSession();
if (hit) beginRename(hit.row, hit.session);
},
"list.kill": () => {
const hit = highlightedSession();
if (hit) requestKill(hit.session, sessionName(hit.session));
},
"list.focusPane": () => document.dispatchEvent(new CustomEvent(FOCUS_PANE_EVENT)),
}),
);
// The inline rename field lives inside a row and owns its own keys; the set's
// plain-key rule keeps this walk out of it.
treeHost.addEventListener("keydown", (e) => listKeys.handle(e));
/** Signature of what is currently on screen — see `treeSignature`. */
let renderedSignature: string | null = null;
const refresh = () => {
// Rebuilding the tree now would blow away the field being typed into.
if (renamingId !== null) return;
const state = store.snapshot();
const sessions = [...state.sessions.values()];
const tree = buildTree(sessions, homeDir);
const signature = sessions.length === 0 ? "EMPTY" : treeSignature(tree);
if (signature === renderedSignature) {
for (const row of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const s = row.dataset.sessionId ? state.sessions.get(row.dataset.sessionId) : undefined;
if (s) leafPatches.get(row)?.(s);
}
return;
}
renderedSignature = signature;
treeHost.replaceChildren();
if (sessions.length === 0) {
treeHost.appendChild(renderEmpty(() => requestOpenSpawn()));
return;
}
if (tree.kind === "folder") {
for (const child of tree.children) treeHost.appendChild(renderNode(child, 0));
}
applyHighlights();
applyNav();
};
void getDaemonInfo()
.then((info) => {
homeDir = info.home_dir;
refresh();
})
.catch(() => {
/* absolute paths are a fine fallback */
});
// Dispatched when something outside the store changed what the tree should
// look like — a folder collapsed, a rename ended — none of which the store
// snapshot can show, so the signature check has to be bypassed.
const rerenderHandler = () => {
renderedSignature = null;
refresh();
};
document.addEventListener("catalog-rerender", rerenderHandler);
// The command bar's action page (#151) picks an action and hands it here:
// both flows are the ones the card already has, reached by session rather
// than by row.
const killHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
requestKill(s, sessionName(s));
};
const renameHandler = (ev: Event) => {
const s = (ev as CustomEvent<Session>).detail;
const row = rowForSession(s);
if (row) beginRename(row, s);
};
document.addEventListener(KILL_SESSION_EVENT, killHandler);
document.addEventListener(RENAME_SESSION_EVENT, renameHandler);
/** The card a session is rendered as, expanding whatever folder hides it
* first: the rename prompt lives inside the card, so a collapsed folder
* would otherwise swallow the request. */
function rowForSession(s: Session): HTMLElement | null {
const find = () => treeHost.querySelector<HTMLElement>(`.tree-leaf[data-session-id="${s.id}"]`);
const found = find();
if (found) return found;
// Folder paths are the abbreviated directory's segments joined back up
// (see buildTree), so the session's own path is normalised the same way
// before it is compared against them.
const cwd = sessionTreePath(s);
const path = (cwd ? abbreviateCwd(cwd, homeDir) : "").split("/").filter(Boolean).join("/");
for (const folder of [...collapsed]) {
if (path === folder || path.startsWith(`${folder}/`)) collapsed.delete(folder);
}
rerenderHandler();
return find();
}
// "Spawn similar" (#28) is dispatched here and handled in main.ts, where the
// multi-view controller can seed the spawn modal with the session's dir/command.
const unsubscribe = store.subscribe(refresh);
// Contract granularity: re-render at least every 30s while visible so the
// duration cutover points land within one tick.
const clock = setInterval(() => {
if (document.visibilityState !== "visible") return;
refresh();
}, 30_000);
return {
el,
setActive(id: string | null) {
highlights = {
open: id ? new Set([id]) : new Set(),
focused: id,
};
applyHighlights();
},
setHighlights(h: SidebarHighlights) {
highlights = h;
applyHighlights();
},
setPreview(sessionId: string | null) {
previewing = sessionId;
applyHighlights();
},
focusList() {
// Start from the session on screen — that is where the user's attention
// already is — falling back to the standing highlight, then the top row.
const preferred = highlights.focused ? `session:${highlights.focused}` : null;
const start = rowFor(preferred) ?? rowFor(navKey) ?? navRows()[0];
navKey = start?.dataset.navKey ?? null;
applyNav();
treeHost.focus();
start?.scrollIntoView({ block: "nearest" });
},
hasListFocus() {
return treeHost.contains(document.activeElement);
},
dispose() {
clearInterval(clock);
status?.dispose();
// An unfinished rename would otherwise leave every later sidebar
// refusing to refresh.
renamingId = null;
document.removeEventListener("catalog-rerender", rerenderHandler);
document.removeEventListener(KILL_SESSION_EVENT, killHandler);
document.removeEventListener(RENAME_SESSION_EVENT, renameHandler);
unsubscribe();
},
};
}
-106
View File
@@ -1,106 +0,0 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { within } from "@testing-library/react";
import { renderSidebar } from "./catalog.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"));
});
const ID = "11111111-2222-3333-4444-555555555555";
function session(overrides: Partial<Session> = {}): Session {
return {
id: ID,
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
/** A store whose updates actually reach subscribers, unlike the one-shot
* harness in catalog.test.ts. */
function liveStore(initial: Session[]) {
const state = { sessions: new Map(initial.map((s) => [s.id, s])) };
const listeners = new Set<CatalogListener>();
const store = {
snapshot: () => state,
subscribe: (fn: CatalogListener) => {
listeners.add(fn);
fn(state);
return () => listeners.delete(fn);
},
} as unknown as CatalogStore;
const push = (sessions: Session[]) => {
state.sessions = new Map(sessions.map((s) => [s.id, s]));
for (const fn of listeners) fn(state);
};
return { store, push };
}
const rowOf = (el: HTMLElement) => el.querySelector<HTMLElement>(".tree-leaf")!;
describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
it("patches an activity change into the standing row instead of rebuilding it", () => {
const { store, push } = liveStore([session({ activity: "Idle" })]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ activity: "AwaitingInput" })]);
// Same element: a rebuild under the pointer would drop `:hover` and restart
// the row's transitions, which is the flicker this avoids.
expect(rowOf(el)).toBe(before);
// …and the card still tells the truth about the session.
expect(
within(before).getByRole("img", { name: "Awaiting input" }).getAttribute("data-activity"),
).toBe("awaiting-input");
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(before.getAttribute("aria-label")).toContain("Awaiting input");
});
it("rebuilds when the row set changes", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session(), session({ id: "99999999-2222-3333-4444-555555555555", command: "bash" })]);
expect(el.querySelectorAll(".tree-leaf")).toHaveLength(2);
expect(rowOf(el)).not.toBe(before);
});
it("rebuilds when a session dies", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ status: "Dead" })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).dataset.status).toBe("dead");
});
it("rebuilds when a session is renamed", () => {
const { store, push } = liveStore([session()]);
const { el } = renderSidebar(store, {});
const before = rowOf(el);
push([session({ metadata: { name: "harmony" } })]);
expect(rowOf(el)).not.toBe(before);
expect(within(rowOf(el)).getByText("harmony").textContent).toBe("harmony");
});
});
-722
View File
@@ -1,722 +0,0 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
KILL_SESSION_EVENT,
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "./catalog.js";
import {
barGroups,
barRows,
closeCommandBar,
commandBarCandidates,
dedupeCommands,
directoryBarCandidates,
directoryCandidate,
isCommandBarOpen,
openCommandBar,
orderSessionsForBar,
parseBarQuery,
sessionBarCandidates,
sessionActionCandidates,
sessionCandidate,
SPAWN_DIRECTORY_EVENT,
} from "./commandBar.js";
import { COMMAND_SPECS, type Command } from "./commands.js";
import type { Session } from "./types.js";
function session(id: string, over: Partial<Session> = {}): Session {
return {
id,
cwd: "/home/me/dev/api",
command: "bash",
env: {},
metadata: { name: id },
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
};
}
afterEach(() => {
closeCommandBar();
document.body.replaceChildren();
});
function input(): HTMLInputElement {
return screen.getByRole("combobox") as HTMLInputElement;
}
function press(key: string, init: KeyboardEventInit = {}): void {
fireEvent.keyDown(input(), { key, ...init });
}
function type(value: string): void {
// The bar re-renders off a native "input" listener, not React's "change" —
// fireEvent.input is the one that actually reaches it.
fireEvent.input(input(), { target: { value } });
}
function rowNames(): string[] {
return screen
.queryAllByRole("option")
.map((row) => row.querySelector(".cmdbar-row-name")?.textContent ?? "");
}
function activeName(): string | undefined {
const active = screen.queryByRole("option", { selected: true });
return active?.querySelector(".cmdbar-row-name")?.textContent ?? undefined;
}
describe("empty-query ordering", () => {
it("sorts most recently focused first", () => {
const older = session("older", { last_focused_at: "2026-01-01T10:00:00Z" });
const newer = session("newer", { last_focused_at: "2026-01-01T11:00:00Z" });
expect(orderSessionsForBar([older, newer]).map((s) => s.id)).toEqual(["newer", "older"]);
});
it("sorts a never-focused session after every focused one, newest created first", () => {
const focused = session("focused", {
last_focused_at: "2020-01-01T00:00:00Z",
created_at: "2020-01-01T00:00:00Z",
});
const oldFresh = session("old-fresh", { created_at: "2026-01-01T00:00:00Z" });
const newFresh = session("new-fresh", { created_at: "2026-06-01T00:00:00Z" });
expect(orderSessionsForBar([oldFresh, newFresh, focused]).map((s) => s.id)).toEqual([
"focused",
"new-fresh",
"old-fresh",
]);
});
it("sorts dead sessions after live ones regardless of recency", () => {
const deadRecent = session("dead", {
status: "Dead",
last_focused_at: "2026-06-01T00:00:00Z",
});
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
const liveNever = session("never");
expect(orderSessionsForBar([deadRecent, liveNever, liveStale]).map((s) => s.id)).toEqual([
"live",
"never",
"dead",
]);
});
it("treats a null last_focused_at as never focused", () => {
const nulled = session("nulled", { last_focused_at: null });
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
expect(orderSessionsForBar([nulled, stamped]).map((s) => s.id)).toEqual(["stamped", "nulled"]);
});
});
describe("session candidates", () => {
it("carries the candidate table's fields, with the directory abbreviated", () => {
const s = session("api", {
metadata: { name: "api" },
command: "claude --resume",
current_cwd: "/home/me/dev/api",
});
expect(sessionCandidate(s, "/home/me")).toEqual({
kind: "session",
session: s,
fields: {
name: "api",
directory: "~/dev/api",
command: "claude --resume",
tool: "claude",
},
});
});
it("leaves the tool out when the program has no mark, and groups a worktree by its repo", () => {
const s = session("edit", {
metadata: {},
command: "hx src/",
worktree: { path: "/home/me/dev/api/wt/x", base_repo: "/home/me/dev/api" },
});
const candidate = sessionCandidate(s, "/home/me");
expect(candidate.fields.tool).toBeUndefined();
expect(candidate.fields.directory).toBe("~/dev/api");
// No `metadata.name`: the sidebar's fallback is the command line.
expect(candidate.fields.name).toBe("hx src/");
});
it("builds candidates in the empty-query order", () => {
const a = session("a", { last_focused_at: "2026-01-01T10:00:00Z" });
const b = session("b", { last_focused_at: "2026-01-01T11:00:00Z" });
expect(sessionBarCandidates([a, b]).map((c) => c.session.id)).toEqual(["b", "a"]);
});
});
describe("rows and grouping", () => {
it("an empty query keeps every session in input order", () => {
const candidates = sessionBarCandidates([session("a"), session("b")]);
expect(barRows("", candidates).map((r) => r.candidate.session.id)).toEqual(["a", "b"]);
});
it("groups by kind without reordering the ranking", () => {
const candidates = sessionBarCandidates([
session("worker", { metadata: { name: "worker" }, current_cwd: "/home/me/dev/api" }),
session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/web" }),
]);
const rows = barRows("api", candidates);
const groups = barGroups(rows);
expect(groups).toHaveLength(1);
expect(groups[0].title).toBe("Sessions");
expect(groups[0].results.map((r) => r.candidate.session.id)).toEqual(
rows.map((r) => r.candidate.session.id),
);
// F6: the name beats the directory, and the directory match highlights
// inside the directory.
expect(groups[0].results[0].candidate.session.id).toBe("api");
expect(groups[0].results[1].field).toBe("directory");
});
});
describe("the overlay", () => {
const sessions = [
session("api", {
metadata: { name: "api" },
current_cwd: "/home/me/dev/api",
last_focused_at: "2026-01-01T11:00:00Z",
}),
session("worker", {
metadata: { name: "worker" },
current_cwd: "/home/me/dev/worker",
last_focused_at: "2026-01-01T10:00:00Z",
}),
session("notes", { metadata: { name: "notes" }, current_cwd: "/home/me/notes" }),
];
// Sessions only: the mixed list's command half is the sigil describe's
// business, and the registry is whatever another test file happened to bind.
function open(over: Parameters<typeof openCommandBar>[0] | null = null) {
return openCommandBar(
over ?? { sessions, homeDir: "/home/me", commands: [], onOpenSession: () => {} },
);
}
it("is a registry command, so the help overlay lists it", () => {
const spec = COMMAND_SPECS.find((s) => s.id === "bar.open");
expect(spec?.keys).toEqual(["ctrl-k"]);
expect(spec?.scope).toBe("global");
});
it("lists sessions by recency under a kind header on an empty query", () => {
open();
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
"Sessions",
]);
expect(rowNames()).toEqual(["api", "worker", "notes"]);
expect(activeName()).toBe("api");
});
it("opening twice leaves one overlay", () => {
open();
open();
expect(screen.getAllByRole("dialog")).toHaveLength(1);
expect(isCommandBarOpen()).toBe(true);
});
it("ranks a typed query through the matcher and highlights the matched range", () => {
open();
type("wor");
expect(rowNames()).toEqual(["worker"]);
const marks = [...document.querySelectorAll(".cmdbar-row-name mark")].map((m) => m.textContent);
expect(marks).toEqual(["wor"]);
});
it("highlights inside the directory when that is the field that matched", () => {
open();
type("notes");
expect(screen.getByRole("option")).toHaveAttribute("data-session-id", "notes");
type("dev/wor");
expect(rowNames()).toEqual(["worker"]);
expect(
[...document.querySelectorAll(".cmdbar-row-meta mark")].map((m) => m.textContent).join(""),
).toBe("dev/wor");
});
it("renders a no-results state rather than an empty list", () => {
open();
type("zzz");
expect(rowNames()).toEqual([]);
expect(screen.getByText("No matches")).toBeDefined();
});
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
open();
press("ArrowUp");
expect(activeName()).toBe("api");
press("ArrowDown");
expect(activeName()).toBe("worker");
press("n", { ctrlKey: true });
expect(activeName()).toBe("notes");
press("ArrowDown");
expect(activeName()).toBe("notes");
press("p", { ctrlKey: true });
expect(activeName()).toBe("worker");
});
it("puts the highlight back on the best row whenever the query changes", () => {
open();
press("ArrowDown");
expect(activeName()).toBe("worker");
type("o");
expect(activeName()).toBe(rowNames()[0]);
});
it("opens the highlighted session through the shared open path on Enter", () => {
const seen: string[] = [];
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
document.addEventListener(SESSION_SELECT_EVENT, listener);
openCommandBar({ sessions, homeDir: "/home/me", commands: [] });
press("ArrowDown");
press("Enter");
document.removeEventListener(SESSION_SELECT_EVENT, listener);
expect(seen).toEqual(["worker"]);
expect(isCommandBarOpen()).toBe(false);
});
it("Enter on an empty result list does nothing", () => {
const onOpenSession = vi.fn();
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
type("zzz");
press("Enter");
expect(onOpenSession).not.toHaveBeenCalled();
expect(isCommandBarOpen()).toBe(true);
});
it("closes on Escape and on a press outside the panel", () => {
open();
press("Escape");
expect(isCommandBarOpen()).toBe(false);
open();
fireEvent.mouseDown(screen.getByRole("dialog"));
expect(isCommandBarOpen()).toBe(false);
});
it("stays open for a press inside the panel", () => {
open();
fireEvent.mouseDown(document.querySelector(".cmdbar-panel") as HTMLElement);
expect(isCommandBarOpen()).toBe(true);
});
it("returns the keyboard to whatever held it when it opened", () => {
const before = document.createElement("button");
document.body.appendChild(before);
before.focus();
open();
expect(screen.getByRole("combobox")).toHaveFocus();
press("Escape");
expect(document.activeElement).toBe(before);
});
it("clicking a row opens that session", () => {
const onOpenSession = vi.fn();
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
fireEvent.click(screen.getAllByRole("option")[1]);
expect(onOpenSession).toHaveBeenCalledWith("worker");
expect(isCommandBarOpen()).toBe(false);
});
});
// ---- the sigils (#150) ----
function command(id: string, label: string, keys: string[], run = () => {}): Command {
return { id, label, group: "Test", keys, scope: "global", run };
}
function rowKeys(): string[][] {
return screen
.queryAllByRole("option")
.map((row) => [...row.querySelectorAll("kbd")].map((k) => k.textContent ?? ""));
}
describe("parsing the query line", () => {
it("reads a sigil only in first position", () => {
expect(parseBarQuery(">kill")).toEqual({ sigil: ">", query: "kill" });
expect(parseBarQuery("~/dev")).toEqual({ sigil: "~", query: "/dev" });
expect(parseBarQuery("")).toEqual({ sigil: null, query: "" });
expect(parseBarQuery("api")).toEqual({ sigil: null, query: "api" });
});
it("leaves a sigil character elsewhere in the query literal", () => {
expect(parseBarQuery("api>x")).toEqual({ sigil: null, query: "api>x" });
// The case that matters: the second `~` of a directory query reaches the
// matcher.
expect(parseBarQuery("~~/dev")).toEqual({ sigil: "~", query: "~/dev" });
});
it("returns to the mixed list when the sigil is backspaced away", () => {
expect(parseBarQuery(">").sigil).toBe(">");
expect(parseBarQuery("").sigil).toBeNull();
});
});
describe("command candidates", () => {
it("dedupes by action, not by chord", () => {
const run = () => {};
const kill = command("a.kill", "Kill the session", ["x"], run);
const alias = command("b.kill", "Kill the session", ["ctrl-x"], run);
const other = command("c.rename", "Rename the session", ["r"]);
expect(dedupeCommands([kill, alias, other]).map((c) => c.id)).toEqual(["a.kill", "c.rename"]);
});
it("matches on the label only, and keeps the chord for display", () => {
const candidates = commandBarCandidates([command("a", "Open the spawn form", ["ctrl-k"])]);
expect(candidates[0].fields).toEqual({ label: "Open the spawn form" });
expect(candidates[0].command.keys).toEqual(["ctrl-k"]);
});
});
describe("directory candidates", () => {
it("abbreviates the path it shows and keeps the one it spawns in", () => {
const c = directoryCandidate("/home/me/dev/api", "/home/me");
expect(c).toEqual({ kind: "directory", path: "/home/me/dev/api", fields: { path: "~/dev/api" } });
});
it("expands a typed `~` and dedupes by the directory spawned in", () => {
const paths = ["~/dev/api", "/home/me/dev/api", "/tmp/scratch"];
expect(directoryBarCandidates(paths, "/home/me").map((c) => c.path)).toEqual([
"/home/me/dev/api",
"/tmp/scratch",
]);
});
});
describe("the sigils in the overlay", () => {
const sessions = [session("api", { metadata: { name: "api" }, current_cwd: "/home/me/dev/api" })];
const ran: string[] = [];
const commands = [
command("spawn.open", "Open the spawn form", ["n"], () => ran.push("spawn.open")),
command("list.kill", "Kill the highlighted session", ["x"], () => ran.push("list.kill")),
];
afterEach(() => {
ran.length = 0;
});
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
return openCommandBar({
sessions,
homeDir: "/home/me",
commands,
onOpenSession: () => {},
loadDirectories: async () => [],
listDirEntries: async () => [],
onSpawnDirectory: () => {},
...over,
});
}
it("lists every command in registry order under `>`, with its chord and no header", () => {
open();
type(">");
expect(rowNames()).toEqual(["Open the spawn form", "Kill the highlighted session"]);
expect(rowKeys()).toEqual([["n"], ["x"]]);
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("matches a command on its label and never on its chord", () => {
open();
type(">kill");
expect(rowNames()).toEqual(["Kill the highlighted session"]);
// `n` and `x` are chords, not label text: querying one finds nothing that
// the label does not also justify.
type(">ctrlx");
expect(rowNames()).toEqual([]);
});
it("runs the highlighted command on Enter", () => {
open();
type(">kill");
press("Enter");
expect(ran).toEqual(["list.kill"]);
expect(isCommandBarOpen()).toBe(false);
});
it("keeps the list mixed with no sigil, and returns to it when the sigil goes", () => {
open();
type("s");
expect([...document.querySelectorAll(".cmdbar-group-title")].map((h) => h.textContent)).toEqual([
"Sessions",
"Commands",
]);
type(">spawn");
expect(rowNames()).toEqual(["Open the spawn form"]);
// Backspacing the sigil away is ordinary editing.
type("spawn");
expect(rowNames()).toContain("Open the spawn form");
type("");
expect(rowNames()).toEqual(["api"]);
});
it("lists recent dirs then projects under `~`", async () => {
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] });
type("~");
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]));
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("fills the list in from /api/dirs as a path is typed", async () => {
const asked: string[] = [];
open({
loadDirectories: async () => ["/home/me/notes"],
listDirEntries: async (dir) => {
asked.push(dir);
return ["maestro", "api"];
},
});
// The second `~` is a literal query character and reaches the matcher.
type("~~/dev/mae");
await waitFor(() => expect(rowNames()).toEqual(["~/dev/maestro"]));
expect(asked).toEqual(["~/dev/"]);
});
it("spawns in the highlighted directory on Enter", async () => {
const spawned: string[] = [];
open({
loadDirectories: async () => ["~/dev/api"],
onSpawnDirectory: (path) => spawned.push(path),
});
type("~api");
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api"]));
press("Enter");
expect(spawned).toEqual(["/home/me/dev/api"]);
expect(isCommandBarOpen()).toBe(false);
});
it("raises the spawn event when the caller supplies no handler", async () => {
const seen: string[] = [];
const listener = (e: Event) => seen.push((e as CustomEvent<string>).detail);
document.addEventListener(SPAWN_DIRECTORY_EVENT, listener);
openCommandBar({
sessions,
homeDir: "/home/me",
commands,
onOpenSession: () => {},
loadDirectories: async () => ["/home/me/dev/api"],
listDirEntries: async () => [],
});
type("~");
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api"]));
press("Enter");
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
expect(seen).toEqual(["/home/me/dev/api"]);
});
});
describe("the focus round-trip over a pane (#152)", () => {
function terminalTextarea(): HTMLTextAreaElement {
const ta = document.createElement("textarea");
ta.className = "xterm-helper-textarea";
document.body.appendChild(ta);
return ta;
}
it("hands the keyboard back to the terminal that had it", () => {
const ta = terminalTextarea();
ta.focus();
openCommandBar({ sessions: [], commands: [] });
expect(screen.getByRole("combobox")).toHaveFocus();
press("Escape");
expect(document.activeElement).toBe(ta);
});
it("reports no focus change to the daemon, opening or closing", () => {
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
vi.stubGlobal("fetch", fetchMock);
try {
const ta = terminalTextarea();
ta.focus();
openCommandBar({ sessions: [], commands: [] });
press("Escape");
const focusCalls = fetchMock.mock.calls.filter(([url]: unknown[]) =>
String(url).includes("/focus"),
);
expect(focusCalls).toEqual([]);
} finally {
vi.unstubAllGlobals();
}
});
});
describe("the session action page (#151)", () => {
const sessions = [
session("api", {
metadata: { name: "api" },
current_cwd: "/home/me/dev/api",
last_focused_at: "2026-01-01T11:00:00Z",
}),
session("worker", {
metadata: { name: "worker" },
current_cwd: "/home/me/dev/worker",
last_focused_at: "2026-01-01T10:00:00Z",
}),
];
function open(over: Partial<Parameters<typeof openCommandBar>[0]> = {}) {
return openCommandBar({
sessions,
homeDir: "/home/me",
commands: [],
onOpenSession: () => {},
...over,
});
}
function headerText(): string | undefined {
return document.querySelector(".cmdbar-page-header")?.textContent ?? undefined;
}
it("lists kill, rename and spawn similar for the highlighted session", () => {
open();
press("Tab");
expect(rowNames()).toEqual(["Kill session", "Rename session", "Spawn similar"]);
expect(activeName()).toBe("Kill session");
});
it("names the session being acted on", () => {
open();
press("ArrowDown");
press("Tab");
expect(headerText()).toContain("worker");
});
it("drops the kind headers, since the page is one kind", () => {
open();
press("Tab");
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
it("stays fuzzy-searchable over the three actions", () => {
open();
press("Tab");
type("ren");
expect(rowNames()).toEqual(["Rename session"]);
type("spwn");
expect(rowNames()).toEqual(["Spawn similar"]);
type("zzz");
expect(screen.getByText("No matches")).toBeDefined();
});
it("does nothing on a command row", () => {
openCommandBar({
sessions,
homeDir: "/home/me",
commands: [{ ...COMMAND_SPECS[0], run: () => {} } as Command],
onOpenSession: () => {},
});
type(">");
const before = rowNames();
press("Tab");
expect(rowNames()).toEqual(before);
expect(headerText()).toBeUndefined();
});
it("does nothing on a directory row", async () => {
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
type("~");
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api"]));
press("Tab");
expect(rowNames()).toEqual(["~/dev/api"]);
expect(headerText()).toBeUndefined();
});
it("returns to the result list on Escape, with the previous query intact", () => {
open();
type("wor");
press("Tab");
expect(headerText()).toContain("worker");
press("Escape");
expect(isCommandBarOpen()).toBe(true);
expect(headerText()).toBeUndefined();
expect(input().value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
it("returns to the result list on backspace at an empty query", () => {
open();
type("wor");
press("Tab");
type("");
press("Backspace");
expect(headerText()).toBeUndefined();
expect(input().value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
it("keeps backspace ordinary while the page's query has text", () => {
open();
press("Tab");
type("ren");
press("Backspace");
expect(headerText()).toContain("api");
expect(rowNames()).toEqual(["Rename session"]);
});
it("closes the bar when the page's Escape has already been used", () => {
open();
press("Tab");
press("Escape");
press("Escape");
expect(isCommandBarOpen()).toBe(false);
});
it("raises kill, rename and spawn similar with the session, closing the bar", () => {
const seen: string[] = [];
for (const [hook, key] of [
["onKillSession", "Kill session"],
["onRenameSession", "Rename session"],
["onSpawnSimilar", "Spawn similar"],
] as const) {
open({ [hook]: (s: Session) => seen.push(`${hook}:${s.id}`) });
type("wor");
press("Tab");
type(key);
press("Enter");
expect(isCommandBarOpen()).toBe(false);
}
expect(seen).toEqual([
"onKillSession:worker",
"onRenameSession:worker",
"onSpawnSimilar:worker",
]);
});
it("hands each action to the flow it already has, by default", () => {
const seen: string[] = [];
const listeners: Array<[string, EventListener]> = [
[KILL_SESSION_EVENT, (e) => seen.push(`kill:${(e as CustomEvent<Session>).detail.id}`)],
[RENAME_SESSION_EVENT, (e) => seen.push(`rename:${(e as CustomEvent<Session>).detail.id}`)],
[SPAWN_SIMILAR_EVENT, (e) => seen.push(`similar:${(e as CustomEvent<Session>).detail.id}`)],
];
for (const [name, fn] of listeners) document.addEventListener(name, fn);
try {
for (const action of ["Kill session", "Rename session", "Spawn similar"]) {
open();
press("Tab");
type(action);
press("Enter");
}
} finally {
for (const [name, fn] of listeners) document.removeEventListener(name, fn);
}
expect(seen).toEqual(["kill:api", "rename:api", "similar:api"]);
});
it("offers no action beyond the three", () => {
expect(sessionActionCandidates().map((c) => c.action.id)).toEqual([
"kill",
"rename",
"spawn-similar",
]);
});
});
-809
View File
@@ -1,809 +0,0 @@
// The `ctrl-k` command bar (docs/COMMAND-BAR.md): a query line over a result
// list, opened from the sidebar, closed with Escape.
//
// Three kinds live here: sessions and commands, ranked together by default,
// and directories, which need the `~` sigil. `tab` on a session drills into
// its action page (#151), which is the same overlay over three actions.
import { listDir, projects as fetchProjects, recentDirs } from "./api.js";
import {
KILL_SESSION_EVENT,
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "./catalog.js";
import { keyCaps, publishedCommands, type Command } from "./commands.js";
import {
matchAndRank,
type CandidateKind,
type CommandCandidate,
type DirectoryCandidate,
type MatchRange,
type MatchResult,
type SessionCandidate,
} from "./utils/matcher.js";
import { splitPath } from "./paths.js";
import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd, sessionName, sessionTreePath, type Session } from "./types.js";
/** A matcher candidate that remembers what it came from, so a result can be
* acted on without a second lookup. The extra property is invisible to the
* matcher, which only ever reads `kind` and `fields`. */
export type SessionBarCandidate = SessionCandidate & { session: Session };
export type CommandBarCandidate = CommandCandidate & { command: Command };
/** `fields.path` is the abbreviated path the operator reads and the matcher
* ranks; `path` is the one a spawn is made in. */
export type DirectoryBarCandidate = DirectoryCandidate & { path: string };
/** One row of a session's action page. It is a `command` to the matcher — a
* single `label` field, ranked exactly like a registry command — and carries
* the action it stands for instead of a registry entry. */
export type ActionBarCandidate = CommandCandidate & { action: SessionAction };
export type BarCandidate =
SessionBarCandidate | CommandBarCandidate | ActionBarCandidate | DirectoryBarCandidate;
export type BarResult = MatchResult<BarCandidate>;
/** Raised when Enter lands on a directory row. The bar picks the directory; the
* shell owns the spawn, which is the same fast-forward path `N` takes. */
export const SPAWN_DIRECTORY_EVENT = "cmdbar-spawn-directory";
/** One kind's slice of the ranked list, in rank order. */
export interface BarGroup<C extends BarCandidate = BarCandidate> {
kind: CandidateKind;
title: string;
results: MatchResult<C>[];
}
const KIND_TITLES: Record<CandidateKind, string> = {
session: "Sessions",
command: "Commands",
directory: "Directories",
};
// ---- the sigils ----
export type BarSigil = ">" | "~";
export interface BarQuery {
/** The sigil in force, or null for the mixed list. */
sigil: BarSigil | null;
/** What the matcher sees: everything after a sigil, or the whole query. */
query: string;
}
/** Split a raw query line into its sigil and the query proper.
*
* A sigil is only a sigil in first position: `>` or `~` anywhere else is a
* literal query character, which is what a directory query with a second `~`
* in it depends on. Backspacing past a sigil needs no special case either —
* the first character goes and the query is a mixed one again. */
export function parseBarQuery(raw: string): BarQuery {
const first = raw[0];
if (first === ">" || first === "~") return { sigil: first, query: raw.slice(1) };
return { sigil: null, query: raw };
}
// ---- the session action page ----
/** The actions a session's action page offers, and no others: the bar picks
* one and hands off to the flow that already implements it
* (docs/COMMAND-BAR.md, "Drill-in"). */
export type SessionActionId = "kill" | "rename" | "spawn-similar";
export interface SessionAction {
id: SessionActionId;
label: string;
}
export const SESSION_ACTIONS: readonly SessionAction[] = [
{ id: "kill", label: "Kill session" },
{ id: "rename", label: "Rename session" },
{ id: "spawn-similar", label: "Spawn similar" },
];
/** The action page's candidates, in the order above — which is the order an
* empty query lists them in, since the matcher keeps input order. */
export function sessionActionCandidates(): ActionBarCandidate[] {
return SESSION_ACTIONS.map((action) => ({
kind: "command",
action,
fields: { label: action.label },
}));
}
/** Narrow a `command` candidate to an action row. Both share the matcher's
* `command` kind, so what separates them is what they carry. */
export function isActionCandidate(candidate: BarCandidate): candidate is ActionBarCandidate {
return candidate.kind === "command" && "action" in candidate;
}
// ---- candidates ----
function focusedAt(s: Session): number | null {
const stamp = s.last_focused_at;
if (typeof stamp !== "string" || stamp === "") return null;
const ms = Date.parse(stamp);
return Number.isNaN(ms) ? null : ms;
}
function createdAt(s: Session): number {
const ms = Date.parse(s.created_at ?? "");
return Number.isNaN(ms) ? 0 : ms;
}
/** The empty-query order (docs/COMMAND-BAR.md, "Empty query"): most recently
* focused first; a session that has never been focused sorts after every
* session that has, most recently created first among those; and a dead
* session sorts after every live one regardless of recency, because a session
* you cannot attach to is not where you just were.
*
* This is also the input order for a non-empty query, which is what the
* matcher's last tie-break preserves. */
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
return [...sessions].sort((a, b) => {
const deadA = a.status === "Dead" ? 1 : 0;
const deadB = b.status === "Dead" ? 1 : 0;
if (deadA !== deadB) return deadA - deadB;
const focusA = focusedAt(a);
const focusB = focusedAt(b);
if ((focusA === null) !== (focusB === null)) return focusA === null ? 1 : -1;
if (focusA !== null && focusB !== null && focusA !== focusB) return focusB - focusA;
return createdAt(b) - createdAt(a);
});
}
/** Build one session's candidate: the fields docs/COMMAND-BAR.md's candidate
* table names, with the directory abbreviated the way the sidebar shows it. */
export function sessionCandidate(s: Session, homeDir?: string): SessionBarCandidate {
const treePath = sessionTreePath(s);
const directory = treePath ? abbreviateCwd(treePath, homeDir) : undefined;
const tool = splitCommand(s.command).tool ?? undefined;
return {
kind: "session",
session: s,
fields: {
name: sessionName(s),
...(directory ? { directory } : {}),
...(s.command ? { command: s.command } : {}),
...(tool ? { tool } : {}),
},
};
}
/** Every session as a candidate, in the empty-query order. */
export function sessionBarCandidates(
sessions: readonly Session[],
homeDir?: string,
): SessionBarCandidate[] {
return orderSessionsForBar(sessions).map((s) => sessionCandidate(s, homeDir));
}
/** Drop the commands that would list twice. Several chords can be bound to one
* action — the registry already carries its aliases on one entry, but nothing
* stops two entries running the same handler, and listed naively that is two
* identical rows. The dedupe is by **action**, never by chord: the chord is
* displayed, so a command with two of them is still one row. */
export function dedupeCommands(commands: readonly Command[]): Command[] {
const seenRun = new Set<Command["run"]>();
const seenLabel = new Set<string>();
const kept: Command[] = [];
for (const command of commands) {
if (seenRun.has(command.run) || seenLabel.has(command.label)) continue;
seenRun.add(command.run);
seenLabel.add(command.label);
kept.push(command);
}
return kept;
}
/** Every bound command as a candidate, in registry order. Only the label is a
* field: the chord is displayed beside the row, never matched, because
* matching it would make `>` results depend on a user's rebinds. */
export function commandBarCandidates(commands: readonly Command[]): CommandBarCandidate[] {
return dedupeCommands(commands).map((command) => ({
kind: "command",
command,
fields: { label: command.label },
}));
}
/** `~/dev/api` back to `/home/me/dev/api` — the inverse of `abbreviateCwd`, for
* a path the operator typed or `/api/dirs` completed. */
export function expandHome(path: string, homeDir?: string): string {
if (!homeDir) return path;
if (path === "~") return homeDir;
return path.startsWith("~/") ? `${homeDir}${path.slice(1)}` : path;
}
export function directoryCandidate(path: string, homeDir?: string): DirectoryBarCandidate {
const absolute = expandHome(path, homeDir);
return { kind: "directory", path: absolute, fields: { path: abbreviateCwd(absolute, homeDir) } };
}
/** Candidates for `~`, deduped by the directory they spawn in and keeping the
* first mention of each — recent dirs (newest first) ahead of the project
* list, which is the empty-query order the contract asks for. */
export function directoryBarCandidates(
paths: readonly string[],
homeDir?: string,
): DirectoryBarCandidate[] {
const seen = new Set<string>();
const out: DirectoryBarCandidate[] = [];
for (const path of paths) {
if (path === "") continue;
const candidate = directoryCandidate(path, homeDir);
if (seen.has(candidate.path)) continue;
seen.add(candidate.path);
out.push(candidate);
}
return out;
}
/** The directory list the bar starts from: what the spawn form already knows —
* `GET /api/recent-dirs` and the project list — either alone if the other
* fails. */
export async function loadBarDirectories(): Promise<string[]> {
const [recents, discovered] = await Promise.all([
recentDirs()
.then((r) => r.dirs.map((d) => d.cwd))
.catch(() => [] as string[]),
fetchProjects()
.then((list) => list.map((p) => p.path))
.catch(() => [] as string[]),
]);
return [...recents, ...discovered];
}
// ---- grouping ----
/** Bucket a ranked list by kind for rendering. Grouping is a rendering of the
* ranking, never a reordering of it: a group holds its matches in rank order,
* and groups appear in the order their best-scoring member does — so the first
* row under the first header is the globally best result. */
export function barGroups<C extends BarCandidate>(
results: readonly MatchResult<C>[],
): BarGroup<C>[] {
const groups: BarGroup<C>[] = [];
for (const result of results) {
const kind = result.candidate.kind;
let group = groups.find((g) => g.kind === kind);
if (!group) {
group = { kind, title: KIND_TITLES[kind], results: [] };
groups.push(group);
}
group.results.push(result);
}
return groups;
}
/** The rows a query produces, flat and in rank order — what the highlight walks
* and what Enter acts on. Headers are labels, not rows, so they are never in
* this list. */
export function barRows<C extends BarCandidate>(
query: string,
candidates: readonly C[],
): MatchResult<C>[] {
return matchAndRank(query, candidates);
}
// ---- rendering ----
/** Text with its matched ranges wrapped in `<mark>`. Ranges come from the
* matcher as `[start, end)` character pairs over the field that won. */
export function renderHighlighted(text: string, ranges: readonly MatchRange[]): DocumentFragment {
const frag = document.createDocumentFragment();
let cursor = 0;
for (const [start, end] of ranges) {
if (start > cursor) frag.append(text.slice(cursor, start));
const mark = document.createElement("mark");
mark.className = "cmdbar-hit";
mark.textContent = text.slice(start, end);
frag.append(mark);
cursor = end;
}
if (cursor < text.length) frag.append(text.slice(cursor));
return frag;
}
/** The name line of a row: the session's name, the command's label, or the
* directory's path. */
function rowName(candidate: BarCandidate): string {
switch (candidate.kind) {
case "session":
return candidate.fields.name;
case "command":
return candidate.fields.label;
case "directory":
return candidate.fields.path;
}
}
/** The secondary line under a session row: the field the match was found in
* when that was not the name, so a session found through its directory says
* so. Only sessions have one — a command shows keycaps instead, and a
* directory's path is already the name. */
function metaText(result: BarResult): string | undefined {
if (result.candidate.kind !== "session") return undefined;
const fields = result.candidate.fields;
switch (result.field) {
case "directory":
return fields.directory;
case "command":
return fields.command;
case "tool":
return fields.tool;
default:
return fields.directory;
}
}
function renderRow(result: BarResult, index: number): HTMLElement {
const candidate = result.candidate;
const row = document.createElement("div");
row.className = "cmdbar-row";
row.setAttribute("role", "option");
row.id = `cmdbar-row-${index}`;
row.dataset.index = String(index);
row.dataset.kind = candidate.kind;
if (candidate.kind === "session") {
row.dataset.sessionId = candidate.session.id;
if (candidate.session.status === "Dead") row.classList.add("is-dead");
} else if (isActionCandidate(candidate)) {
row.dataset.actionId = candidate.action.id;
} else if (candidate.kind === "command") {
row.dataset.commandId = candidate.command.id;
} else {
row.dataset.path = candidate.path;
}
const name = document.createElement("span");
name.className = "cmdbar-row-name";
const nameText = rowName(candidate);
const nameMatched =
result.field === "name" || result.field === "label" || result.field === "path";
name.append(nameMatched ? renderHighlighted(nameText, result.ranges) : nameText);
row.appendChild(name);
// The chord is teaching material, not a match target: it is rendered from
// the live registry entry and never fed to the matcher.
if (candidate.kind === "command" && !isActionCandidate(candidate)) {
const keys = document.createElement("span");
keys.className = "cmdbar-row-keys";
for (const cap of keyCaps(candidate.command.keys[0])) {
const kbd = document.createElement("kbd");
kbd.textContent = cap;
keys.appendChild(kbd);
}
row.appendChild(keys);
return row;
}
const meta = metaText(result);
if (meta) {
const el = document.createElement("span");
el.className = "cmdbar-row-meta";
const highlighted = result.field !== undefined && result.field !== "name";
el.append(highlighted ? renderHighlighted(meta, result.ranges) : meta);
row.appendChild(el);
}
return row;
}
const PLACEHOLDERS: Record<"mixed" | BarSigil, string> = {
mixed: "Go to a session…",
">": "Run a command…",
"~": "Spawn in a directory…",
};
// ---- the overlay ----
export interface CommandBarOptions {
/** Sessions to build candidates from, as the store has them right now. */
sessions: readonly Session[];
/** The daemon's home dir, for the directory field's `~/…` abbreviation. */
homeDir?: string;
/** Open the highlighted session. Defaults to the shared open path — the
* sidebar's click event — which is what the contract requires; tests pass
* their own. */
onOpenSession?: (sessionId: string) => void;
/** The command registry to list under `>`. Defaults to every bound command,
* in registry order. */
commands?: readonly Command[];
/** The directory list behind `~`, fetched once on first use. */
loadDirectories?: () => Promise<string[]>;
/** One directory's subdirectories, for filling the list in as a path is
* typed. Defaults to `GET /api/dirs`. */
listDirEntries?: (dir: string) => Promise<string[]>;
/** Spawn a session in the highlighted directory. Defaults to raising
* `SPAWN_DIRECTORY_EVENT`, which the shell turns into a fast-forward
* spawn. */
onSpawnDirectory?: (path: string) => void;
/** The action page's three actions (#151). Each defaults to raising the
* event the flow that already implements it listens on — the sidebar's
* confirm-and-blockers kill, its inline rename prompt, the spawn form
* seeded from the session. The bar picks the action and nothing more. */
onKillSession?: (session: Session) => void;
onRenameSession?: (session: Session) => void;
onSpawnSimilar?: (session: Session) => void;
}
interface OpenBar {
el: HTMLElement;
dispose: () => void;
}
let current: OpenBar | null = null;
export function isCommandBarOpen(): boolean {
return current !== null;
}
export function closeCommandBar(): void {
current?.dispose();
}
/** Mount the bar. Opening while one is up replaces it, so a stray second call
* can never stack two overlays. */
export function openCommandBar(opts: CommandBarOptions): HTMLElement {
closeCommandBar();
// Closing returns the keyboard to whatever held it when the bar opened: a
// round trip through the bar never silently moves focus.
const previouslyFocused = document.activeElement as HTMLElement | null;
const openSession =
opts.onOpenSession ??
((id: string) => document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: id })));
const spawnDirectory =
opts.onSpawnDirectory ??
((path: string) =>
document.dispatchEvent(new CustomEvent(SPAWN_DIRECTORY_EVENT, { detail: path })));
const sessionEvent =
(name: string) =>
(session: Session): void => {
document.dispatchEvent(new CustomEvent(name, { detail: session }));
};
const killSession = opts.onKillSession ?? sessionEvent(KILL_SESSION_EVENT);
const renameSession = opts.onRenameSession ?? sessionEvent(RENAME_SESSION_EVENT);
const spawnSimilar = opts.onSpawnSimilar ?? sessionEvent(SPAWN_SIMILAR_EVENT);
const loadDirectories = opts.loadDirectories ?? loadBarDirectories;
const listEntries =
opts.listDirEntries ?? ((dir: string) => listDir(dir).then((listing) => listing.entries));
const sessionCandidates: SessionBarCandidate[] = sessionBarCandidates(
opts.sessions,
opts.homeDir,
);
const commandCandidates: CommandBarCandidate[] = commandBarCandidates(
opts.commands ?? publishedCommands(),
);
const el = document.createElement("div");
el.className = "cmdbar-overlay";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("aria-label", "Command bar");
const panel = document.createElement("div");
panel.className = "cmdbar-panel";
const input = document.createElement("input");
input.type = "text";
input.className = "cmdbar-input";
input.setAttribute("role", "combobox");
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-controls", "cmdbar-results");
input.setAttribute("aria-label", "Search sessions, commands and directories");
input.setAttribute("autocomplete", "off");
input.spellcheck = false;
input.placeholder = PLACEHOLDERS.mixed;
const results = document.createElement("div");
results.className = "cmdbar-results";
results.id = "cmdbar-results";
results.setAttribute("role", "listbox");
panel.append(input, results);
el.appendChild(panel);
document.body.appendChild(el);
let rows: BarResult[] = [];
let highlighted = 0;
// The action page (#151). `page` is the session being acted on, `stashed`
// the result-list query it was drilled into from — backing out restores it,
// so the round trip costs the operator nothing.
let page: Session | null = null;
let stashed = "";
let header: HTMLElement | null = null;
// `~`'s candidates: the recents-and-projects list, fetched once, plus
// whatever `/api/dirs` has filled in for the directory being typed.
let directories: DirectoryBarCandidate[] = [];
let directoriesRequested = false;
let probed: DirectoryBarCandidate[] = [];
let probedDir: string | null = null;
const listings = new Map<string, string[]>();
function alive(): boolean {
return current?.el === el;
}
/** Fetch the recents-and-projects list the first time `~` is used. */
function ensureDirectories(): void {
if (directoriesRequested) return;
directoriesRequested = true;
loadDirectories()
.then((paths) => {
directories = directoryBarCandidates(paths, opts.homeDir);
if (alive()) render();
})
.catch(() => {
/* no directories is a shorter list, not an error state */
});
}
/** Fill the list in from `GET /api/dirs` once the query looks like a path:
* the directory it names is listed and its children join the candidates, so
* `~` reaches a directory that was never spawned in. */
function probeDirectories(query: string): void {
const { dir } = splitPath(query);
if (dir === "") {
probed = [];
probedDir = null;
return;
}
if (dir === probedDir) return;
probedDir = dir;
const cached = listings.get(dir);
if (cached) {
probed = directoryBarCandidates(
cached.map((entry) => `${dir}${entry}`),
opts.homeDir,
);
return;
}
probed = [];
listEntries(dir)
.then((entries) => {
listings.set(dir, entries);
if (!alive() || probedDir !== dir) return;
probed = directoryBarCandidates(
entries.map((entry) => `${dir}${entry}`),
opts.homeDir,
);
render();
})
.catch(() => {
/* an unreadable directory contributes nothing */
});
}
/** What the matcher sees for the query line as it stands. With no sigil the
* list is mixed — sessions and commands together — except on an empty query,
* where the bar is a session switcher. */
function candidatesFor(parsed: BarQuery): BarCandidate[] {
if (page) return sessionActionCandidates();
switch (parsed.sigil) {
case ">":
return [...commandCandidates];
case "~": {
ensureDirectories();
probeDirectories(parsed.query);
const seen = new Set<string>();
return [...directories, ...probed].filter((c) =>
seen.has(c.path) ? false : (seen.add(c.path), true),
);
}
default:
return parsed.query === ""
? [...sessionCandidates]
: [...sessionCandidates, ...commandCandidates];
}
}
function paintHighlight(): void {
for (const row of results.querySelectorAll<HTMLElement>(".cmdbar-row")) {
const active = Number(row.dataset.index) === highlighted;
row.classList.toggle("is-active", active);
row.setAttribute("aria-selected", active ? "true" : "false");
if (active) {
row.scrollIntoView({ block: "nearest" });
input.setAttribute("aria-activedescendant", row.id);
}
}
if (rows.length === 0) input.removeAttribute("aria-activedescendant");
}
/** The line naming the session being acted on, so the target is never
* ambiguous. Present only while the action page is up. */
function paintHeader(): void {
header?.remove();
header = null;
if (!page) return;
header = document.createElement("div");
header.className = "cmdbar-page-header";
header.setAttribute("role", "presentation");
header.textContent = `Actions for "${sessionName(page)}"`;
panel.insertBefore(header, results);
}
function render(): void {
// On the action page the query line is over the three actions, so there is
// no sigil to read: `>` and `~` are ordinary characters there.
const parsed = page ? { sigil: null, query: input.value } : parseBarQuery(input.value);
input.placeholder = page
? `Act on ${sessionName(page)}…`
: PLACEHOLDERS[parsed.sigil ?? "mixed"];
paintHeader();
rows = barRows(parsed.query, candidatesFor(parsed));
// The highlight starts on the globally best row every time the list
// changes: an old index would point at a different result.
highlighted = 0;
results.replaceChildren();
if (rows.length === 0) {
const empty = document.createElement("div");
empty.className = "cmdbar-empty";
empty.textContent = "No matches";
results.appendChild(empty);
paintHighlight();
return;
}
let index = 0;
for (const group of barGroups(rows)) {
// While a sigil is in force there is one kind, so the header is
// dropped — and the action page is one kind too.
if (parsed.sigil === null && !page) {
const header = document.createElement("div");
header.className = "cmdbar-group-title";
header.setAttribute("role", "presentation");
header.textContent = group.title;
results.appendChild(header);
}
for (const result of group.results) {
const at = index;
const row = renderRow(result, at);
row.addEventListener("click", () => activate(at));
results.appendChild(row);
index += 1;
}
}
paintHighlight();
}
/** The highlight does not wrap: it stops at the first and last row. */
function move(delta: number): void {
if (rows.length === 0) return;
highlighted = Math.min(rows.length - 1, Math.max(0, highlighted + delta));
paintHighlight();
}
/** `tab`: drill into the highlighted session's action page. No effect on any
* other kind, and none on the page itself, which has no session rows. */
function drillIn(): void {
const candidate = rows[highlighted]?.candidate;
if (page || candidate?.kind !== "session") return;
page = candidate.session;
stashed = input.value;
input.value = "";
render();
}
/** Escape, or backspace at an empty query: back to the result list with the
* query the drill-in was made from. */
function leavePage(): void {
page = null;
input.value = stashed;
stashed = "";
render();
}
function runAction(action: SessionAction, session: Session): void {
close();
switch (action.id) {
case "kill":
killSession(session);
return;
case "rename":
renameSession(session);
return;
case "spawn-similar":
spawnSimilar(session);
return;
}
}
function activate(index: number): void {
const result = rows[index];
if (!result) return;
const candidate = result.candidate;
if (isActionCandidate(candidate)) {
// The page cannot be up without a session to act on.
if (page) runAction(candidate.action, page);
return;
}
close();
switch (candidate.kind) {
case "session":
openSession(candidate.session.id);
return;
case "command":
candidate.command.run();
return;
case "directory":
spawnDirectory(candidate.path);
return;
}
}
function onKeydown(e: KeyboardEvent): void {
switch (e.key) {
case "Escape":
e.preventDefault();
// On an action page Escape is one level up, not the way out.
if (page) leavePage();
else close();
return;
case "Tab":
e.preventDefault();
drillIn();
return;
case "Backspace":
// Ordinary editing, except at an empty query on the action page.
if (page && input.value === "") {
e.preventDefault();
leavePage();
}
return;
case "Enter":
e.preventDefault();
activate(highlighted);
return;
case "ArrowDown":
e.preventDefault();
move(1);
return;
case "ArrowUp":
e.preventDefault();
move(-1);
return;
case "n":
if (e.ctrlKey) {
e.preventDefault();
move(1);
}
return;
case "p":
if (e.ctrlKey) {
e.preventDefault();
move(-1);
}
return;
}
}
function close(): void {
if (current?.el !== el) return;
current = null;
el.remove();
// Restoring focus is what makes the bar a view over the app rather than a
// place in it; a detached node cannot take it.
if (previouslyFocused?.isConnected) previouslyFocused.focus();
}
input.addEventListener("keydown", onKeydown);
input.addEventListener("input", render);
// A press on the scrim closes; one inside the panel is left alone.
el.addEventListener("mousedown", (ev) => {
if (!panel.contains(ev.target as Node)) close();
});
current = { el, dispose: close };
render();
input.focus();
return el;
}
@@ -7,7 +7,7 @@ import {
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "../../catalog.js";
} from "../../uiEvents.js";
import { COMMAND_SPECS, type Command } from "../../commands.js";
import type { Session } from "../../types.js";
import { CommandBar, SPAWN_DIRECTORY_EVENT, type CommandBarProps } from "./CommandBar.js";
+1 -1
View File
@@ -14,7 +14,7 @@ import {
RENAME_SESSION_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "../../catalog.js";
} from "../../uiEvents.js";
import { publishedCommands, type Command } from "../../commands.js";
import { sessionName, type Session } from "../../types.js";
import { ActionPage } from "./ActionPage.js";
@@ -2,7 +2,7 @@
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js";
import { OPEN_COMMAND_BAR_EVENT } from "../../uiEvents.js";
import { useCommandBar, type UseCommandBarOptions } from "./useCommandBar.js";
let controls: ReturnType<typeof useCommandBar>;
@@ -5,7 +5,7 @@
import { useCallback, useEffect, useState } from "react";
import { OPEN_COMMAND_BAR_EVENT } from "../../catalog.js";
import { OPEN_COMMAND_BAR_EVENT } from "../../uiEvents.js";
import { CommandSet, defineCommands, isBarTypingTarget } from "../../commands.js";
export interface UseCommandBarOptions {
@@ -1,8 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { DaemonLink, compareIdentity, formatRelease } from "./daemonLink.js";
import type { Scheduler } from "./events.js";
import type { DaemonInfo } from "./types.js";
import { GRACE_MS, POLL_MS, compareIdentity, createDaemonLinkStore, formatRelease, type Scheduler } from "./DaemonLink.js";
import type { DaemonInfo } from "../../types.js";
function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo {
return {
@@ -14,14 +13,18 @@ function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:0
};
}
/** Scheduler whose timers only fire when the test says so. */
function manual(): Scheduler & { run(): void; pending(): number } {
/** Scheduler whose timers only fire when the test says so, recording the
* delay each was scheduled with. */
function manual(): Scheduler & { run(): void; pending(): number; delays: number[] } {
let next = 1;
const timers = new Map<number, () => void>();
const delays: number[] = [];
return {
setTimer(fn) {
delays,
setTimer(fn, ms) {
const h = next++;
timers.set(h, fn);
delays.push(ms);
return h;
},
clearTimer(h) {
@@ -73,21 +76,22 @@ describe("formatRelease", () => {
});
});
describe("DaemonLink", () => {
it("starts online once the first probe lands", async () => {
const link = new DaemonLink({
describe("daemon link store", () => {
it("starts in the connecting state until the first probe lands", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => info("aaa", "i1"),
scheduler: manual(),
reload: () => {},
});
await link.start();
expect(link.status().state).toBe("online");
expect(link.status().build?.commit).toBe("aaa");
expect(store.getState().state).toBe("connecting");
await store.getState().probe();
expect(store.getState().state).toBe("online");
expect(store.getState().build?.commit).toBe("aaa");
});
it("holds the modal back during the grace period, then blocks", async () => {
const sched = manual();
const link = new DaemonLink({
const store = createDaemonLinkStore({
fetchInfo: async () => {
throw new Error("down");
},
@@ -95,19 +99,19 @@ describe("DaemonLink", () => {
reload: () => {},
graceMs: 1000,
});
link.noteDisconnected();
store.getState().noteDisconnected();
// Still inside the grace window: the dot goes amber, nothing blocks.
expect(link.status().state).toBe("reconnecting");
expect(store.getState().state).toBe("reconnecting");
sched.run();
await Promise.resolve();
expect(link.status().state).toBe("down");
expect(store.getState().state).toBe("down");
});
it("unlocks without reloading when the same build comes back", async () => {
const sched = manual();
const reload = vi.fn();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
const store = createDaemonLinkStore({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
@@ -115,70 +119,45 @@ describe("DaemonLink", () => {
scheduler: sched,
reload,
});
await link.start();
await store.getState().probe();
reply = null;
link.noteDisconnected();
store.getState().noteDisconnected();
sched.run(); // grace expires → blocked, poll scheduled
await Promise.resolve();
expect(link.status().state).toBe("down");
expect(store.getState().state).toBe("down");
// Same commit, new process: a restart, not an upgrade — the page stays.
reply = info("aaa", "i2");
await link.probe();
await store.getState().probe();
expect(reload).not.toHaveBeenCalled();
expect(link.status().state).toBe("online");
});
it("rechecks immediately when the browser returns from the background", async () => {
const sched = manual();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload: () => {},
});
await link.start();
reply = null;
link.noteDisconnected();
sched.run();
await Promise.resolve();
expect(link.status().state).toBe("down");
reply = info("aaa", "i1");
link.recover();
await Promise.resolve();
expect(link.status().state).toBe("online");
expect(store.getState().state).toBe("online");
});
it("reloads the page when the daemon comes back on a new commit", async () => {
const sched = manual();
const reload = vi.fn();
let reply = info("aaa", "i1");
const link = new DaemonLink({
const store = createDaemonLinkStore({
fetchInfo: async () => reply,
scheduler: sched,
reload,
});
await link.start();
await store.getState().probe();
link.noteDisconnected();
store.getState().noteDisconnected();
reply = info("bbb", "i2");
await link.probe();
await store.getState().probe();
expect(reload).toHaveBeenCalledTimes(1);
// Left blocked deliberately: the reload is what ends this page's life, and
// flashing an unlocked UI in between would let the user act on stale code.
expect(link.status().state).toBe("updating");
expect(store.getState().state).toBe("updating");
});
it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => {
const sched = manual();
let attempts = 0;
const link = new DaemonLink({
const store = createDaemonLinkStore({
fetchInfo: async () => {
attempts += 1;
throw new Error("down");
@@ -186,7 +165,7 @@ describe("DaemonLink", () => {
scheduler: sched,
reload: () => {},
});
link.noteDisconnected();
store.getState().noteDisconnected();
sched.run(); // grace → first poll
await Promise.resolve();
await Promise.resolve();
@@ -196,23 +175,41 @@ describe("DaemonLink", () => {
await Promise.resolve();
await Promise.resolve();
expect(attempts).toBeGreaterThan(first);
link.stop();
store.getState().stop();
});
it("notifies subscribers on every state change", async () => {
const sched = manual();
const seen: string[] = [];
const link = new DaemonLink({
const store = createDaemonLinkStore({
fetchInfo: async () => info("aaa", "i1"),
scheduler: sched,
reload: () => {},
});
link.subscribe((s) => seen.push(s.state));
await link.start();
link.noteDisconnected();
store.subscribe((s) => seen.push(s.state));
await store.getState().probe();
store.getState().noteDisconnected();
sched.run();
await Promise.resolve();
expect(seen).toContain("reconnecting");
expect(seen[0]).toBe("connecting");
});
it("schedules the grace timer and poll at the documented defaults", async () => {
const sched = manual();
const store = createDaemonLinkStore({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
expect(GRACE_MS).toBe(1200);
expect(POLL_MS).toBe(700);
store.getState().noteDisconnected();
expect(sched.delays).toEqual([GRACE_MS]);
sched.run(); // grace fires → schedules the first poll
await Promise.resolve();
await Promise.resolve();
expect(sched.delays).toEqual([GRACE_MS, POLL_MS]);
});
});
@@ -0,0 +1,71 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDaemonLinkStore } from "./DaemonLink.js";
import { DaemonLink } from "./DaemonLink.js";
afterEach(() => cleanup());
describe("DaemonLink", () => {
it("probes once on mount, regardless of the initial connected value", async () => {
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
render(<DaemonLink store={store} connected={false} />);
await Promise.resolve();
expect(fetchInfo).toHaveBeenCalledTimes(1);
});
it("does not treat the initial connected value as a transition", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
scheduler: { setTimer: () => 0, clearTimer: () => {} },
});
render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
// Only the mount probe ran — no extra noteConnected() probe on top of it.
expect(store.getState().state).toBe("online");
});
it("forwards a later connected drop to noteDisconnected", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
scheduler: { setTimer: () => 0, clearTimer: () => {} },
});
const { rerender } = render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(store.getState().state).toBe("online");
rerender(<DaemonLink store={store} connected={false} />);
expect(store.getState().state).toBe("reconnecting");
});
it("forwards a later connected recovery to noteConnected", async () => {
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
const { rerender } = render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(store.getState().state).toBe("online");
// The socket dropped: noteDisconnected() moves off "online" so the coming
// recovery isn't a no-op.
rerender(<DaemonLink store={store} connected={false} />);
expect(store.getState().state).toBe("reconnecting");
fetchInfo.mockClear();
rerender(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(fetchInfo).toHaveBeenCalledTimes(1);
expect(store.getState().state).toBe("online");
});
it("stops the store's polling on unmount", () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
});
const stop = vi.spyOn(store.getState(), "stop");
const { unmount } = render(<DaemonLink store={store} connected={false} />);
unmount();
expect(stop).toHaveBeenCalledTimes(1);
});
});
+234
View File
@@ -0,0 +1,234 @@
// React port of ../../daemonLink.ts. The link to the daemon behind this page:
// is it up, and which release is it? `RestartOverlay` (./RestartOverlay.tsx)
// renders what this reports. See docs/RESTART.md for the restart contract.
import { useEffect, useRef } from "react";
import { create } from "zustand";
import { getDaemonInfo } from "../../api.js";
import type { BuildInfo, DaemonInfo } from "../../types.js";
/**
* - `connecting` — first probe hasn't landed yet.
* - `online` — the daemon answered and is the same process, or a resolved new one.
* - `reconnecting` — the socket dropped, but within the grace window; a blip
* shouldn't throw a modal in the user's face.
* - `down` — unreachable past the grace window: the overlay blocks the UI and
* the poll runs until it answers.
* - `updating` — it answered on a different commit; the page is reloading and
* stays blocked until it does.
*/
export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating";
/** What happened between two `/api/info` replies. */
export type RestartKind = "same" | "restarted" | "upgraded";
/** How long a dropped socket may stay dropped before the UI blocks. Long enough
* to ride out a wifi hiccup, short enough that a real restart is visible. */
export const GRACE_MS = 1200;
/** Cadence of the `/api/info` poll while the daemon is unreachable. */
export const POLL_MS = 700;
/**
* Compare two daemon replies.
*
* The commit decides first: a different one means new code, which for a browser
* means new assets. The per-process `instance` catches the rest — a restart of
* the same build, which needs no reload. A daemon built outside a git checkout
* reports an empty commit, so an empty-vs-empty pair never counts as an
* upgrade; the instance still reports the restart.
*/
export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
const a = before.build?.commit ?? "";
const b = after.build?.commit ?? "";
if (a && b && a !== b) return "upgraded";
if (before.instance && after.instance && before.instance !== after.instance) return "restarted";
return "same";
}
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when
* the build carried uncommitted changes. Empty builds say so rather than
* rendering a blank tooltip. */
export function formatRelease(build: BuildInfo | undefined): string {
if (!build?.commit) return "unknown release";
const day = build.commit_date ? build.commit_date.slice(0, 10) : "";
const parts = [build.commit];
if (day) parts.push(`· ${day}`);
if (build.dirty) parts.push("(dirty)");
return parts.join(" ");
}
/** Schedules a probe/grace timer; returns a handle `clearTimer` understands. */
export interface Scheduler {
setTimer(fn: () => void, ms: number): number;
clearTimer(handle: number): void;
}
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
export interface DaemonLinkState {
state: LinkState;
/** The release the daemon last reported. Undefined until the first probe,
* and on a daemon too old to carry one. */
build?: BuildInfo;
/** Hostname the daemon reports, for the indicator's tooltip. */
hostname?: string;
/** How many probes have failed in the current outage — the overlay counts
* attempts so a long restart doesn't look frozen. */
attempts: number;
}
export interface DaemonLinkActions {
/** One `/api/info` round-trip. Resolves `true` when the daemon answered. */
probe(): Promise<boolean>;
/** The events socket dropped. Starts the grace timer and the poll. */
noteDisconnected(): void;
/** The events socket came back. It carries no identity of its own, so the
* state only clears once a probe confirms *which* daemon answered — a
* reconnect to an upgraded daemon must reload, not resume. */
noteConnected(): void;
/** Stop polling. */
stop(): void;
}
export type DaemonLinkStore = DaemonLinkState & DaemonLinkActions;
export interface DaemonLinkOptions {
/** Injectable for tests; defaults to `GET /api/info`. */
fetchInfo?: () => Promise<DaemonInfo>;
scheduler?: Scheduler;
/** What an upgrade does. Injectable so tests don't navigate. */
reload?: () => void;
graceMs?: number;
pollMs?: number;
}
/** Builds an isolated daemon-link store — same shape as
* {@link ../../stores/catalogStore.js!createCatalogStore}: both a React hook
* and a plain object (`getState`/`subscribe`), so it drives the overlay
* whether or not React is the one calling `noteConnected`/`noteDisconnected`. */
export function createDaemonLinkStore(opts: DaemonLinkOptions = {}) {
const fetchInfo = opts.fetchInfo ?? getDaemonInfo;
const scheduler = opts.scheduler ?? browserScheduler;
const reload = opts.reload ?? (() => window.location.reload());
const graceMs = opts.graceMs ?? GRACE_MS;
const pollMs = opts.pollMs ?? POLL_MS;
let identity: DaemonInfo | null = null;
let graceTimer: number | null = null;
let pollTimer: number | null = null;
return create<DaemonLinkStore>((set, get) => {
function clearTimers(): void {
if (graceTimer !== null) {
scheduler.clearTimer(graceTimer);
graceTimer = null;
}
if (pollTimer !== null) {
scheduler.clearTimer(pollTimer);
pollTimer = null;
}
}
async function probe(): Promise<boolean> {
let info: DaemonInfo;
try {
info = await fetchInfo();
} catch {
set((s) => ({ attempts: s.attempts + 1 }));
return false;
}
const before = identity;
identity = info;
if (before && compareIdentity(before, info) === "upgraded") {
// Stay blocked through the reload: an unlocked frame here would let the
// user drive new server code from the bundle it just replaced.
set({ state: "updating", build: info.build, hostname: info.hostname });
reload();
return true;
}
clearTimers();
set({ state: "online", build: info.build, hostname: info.hostname, attempts: 0 });
return true;
}
async function poll(): Promise<void> {
const state = get().state;
if (state === "online" || state === "updating") return;
const ok = await probe();
if (ok || pollTimer !== null) return;
pollTimer = scheduler.setTimer(() => {
pollTimer = null;
void poll();
}, pollMs);
}
return {
state: "connecting",
build: undefined,
hostname: undefined,
attempts: 0,
probe,
noteDisconnected() {
const state = get().state;
if (state === "down" || state === "updating") return;
set({ state: "reconnecting", attempts: 0 });
if (graceTimer === null) {
graceTimer = scheduler.setTimer(() => {
graceTimer = null;
if (get().state === "reconnecting") set({ state: "down" });
void poll();
}, graceMs);
}
},
noteConnected() {
const state = get().state;
if (state === "online" || state === "updating") return;
void probe();
},
stop: clearTimers,
};
});
}
export type DaemonLinkStoreApi = ReturnType<typeof createDaemonLinkStore>;
export interface DaemonLinkProps {
/** The link store this instance drives — `RestartOverlay` (and, later, the
* sidebar's status indicator) read the same one. */
store: DaemonLinkStoreApi;
/** The catalog store's `connected` field (docs/RESTART.md): the events
* socket dying is the earliest signal a restart happened, ahead of any
* poll. Pass `useStore(catalogStore, (s) => s.connected)`. */
connected: boolean;
}
/**
* Renders nothing — the controller that wires a `DaemonLinkStore` to the
* catalog's socket state. Mount once near the app root; `RestartOverlay`
* reads the store it drives.
*/
export function DaemonLink({ store, connected }: DaemonLinkProps): null {
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
void store.getState().probe();
return;
}
if (connected) store.getState().noteConnected();
else store.getState().noteDisconnected();
}, [store, connected]);
useEffect(() => () => store.getState().stop(), [store]);
return null;
}
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDaemonLinkStore, type DaemonLinkStoreApi } from "./DaemonLink.js";
import { RestartOverlay } from "./RestartOverlay.js";
afterEach(() => cleanup());
function storeAt(state: "connecting" | "online" | "reconnecting" | "down" | "updating"): DaemonLinkStoreApi {
const store = createDaemonLinkStore({ scheduler: { setTimer: () => 0, clearTimer: () => {} } });
store.setState({ state });
return store;
}
describe("RestartOverlay", () => {
it("renders nothing while connecting, online, or reconnecting", () => {
for (const state of ["connecting", "online", "reconnecting"] as const) {
const { unmount } = render(<RestartOverlay store={storeAt(state)} />);
expect(screen.queryByRole("alertdialog")).toBeNull();
unmount();
}
});
it("blocks with a restarting message when down", () => {
render(<RestartOverlay store={storeAt("down")} />);
const dialog = screen.getByRole("alertdialog");
expect(dialog.textContent).toContain("Maestro is restarting");
expect(dialog.textContent).toContain("Waiting for the daemon to come back…");
});
it("counts attempts once more than one probe has failed", () => {
const store = storeAt("down");
store.setState({ attempts: 3 });
render(<RestartOverlay store={store} />);
expect(screen.getByRole("alertdialog").textContent).toContain("(3 attempts)");
});
it("says it is reloading when updating", () => {
render(<RestartOverlay store={storeAt("updating")} />);
const dialog = screen.getByRole("alertdialog");
expect(dialog.textContent).toContain("Maestro was updated");
expect(dialog.textContent).toContain("Reloading this page onto the new release…");
});
it("names the release from the store's build", () => {
const store = storeAt("down");
store.setState({ build: { commit: "f89be4a", commit_date: "2026-09-02T00:00:00Z", dirty: false } });
render(<RestartOverlay store={store} />);
expect(screen.getByRole("alertdialog").textContent).toContain("f89be4a · 2026-09-02");
});
it("clears the moment the store reports the link is back", () => {
const store = storeAt("down");
const { rerender } = render(<RestartOverlay store={store} />);
expect(screen.queryByRole("alertdialog")).not.toBeNull();
store.setState({ state: "online" });
rerender(<RestartOverlay store={store} />);
expect(screen.queryByRole("alertdialog")).toBeNull();
});
it("swallows keyboard shortcuts while blocking, but not once cleared", () => {
const store = storeAt("down");
const handler = vi.fn();
window.addEventListener("keydown", handler);
const { rerender } = render(<RestartOverlay store={store} />);
fireEvent.keyDown(window, { key: "n" });
expect(handler).not.toHaveBeenCalled();
store.setState({ state: "online" });
rerender(<RestartOverlay store={store} />);
fireEvent.keyDown(window, { key: "n" });
expect(handler).toHaveBeenCalledTimes(1);
window.removeEventListener("keydown", handler);
});
});
@@ -0,0 +1,62 @@
// React port of ../../restartOverlay.ts. Blocking overlay for a daemon that
// went away — see docs/RESTART.md.
//
// Rendered while the link is `down` or `updating`, gone the moment it is
// back. It owns no polling of its own: `DaemonLink` polls, this only renders
// what the store reports.
import { useEffect } from "react";
import { useStore } from "zustand";
import { formatRelease, type DaemonLinkStoreApi } from "./DaemonLink.js";
export interface RestartOverlayProps {
/** The same store a sibling `DaemonLink` drives. */
store: DaemonLinkStoreApi;
}
/** The poll runs while this is up; saying how many tries have gone by is what
* separates "still restarting" from "wedged" for someone staring at it. */
function attemptText(attempts: number): string {
if (attempts <= 1) return "Waiting for the daemon to come back…";
return `Waiting for the daemon to come back… (${attempts} attempts)`;
}
function swallow(e: KeyboardEvent): void {
e.stopImmediatePropagation();
}
export function RestartOverlay({ store }: RestartOverlayProps) {
const state = useStore(store, (s) => s.state);
const build = useStore(store, (s) => s.build);
const attempts = useStore(store, (s) => s.attempts);
const blocking = state === "down" || state === "updating";
// The app's shortcuts live on `window`, so hiding the UI behind a backdrop
// does not stop them: `n` would still open a spawn form against a daemon
// that is not there. Swallow keys in the capture phase until the link is
// back, without `preventDefault` so the browser's own chords still work.
useEffect(() => {
if (!blocking) return;
window.addEventListener("keydown", swallow, true);
return () => window.removeEventListener("keydown", swallow, true);
}, [blocking]);
if (!blocking) return null;
const updating = state === "updating";
return (
<div className="modal-backdrop restart-backdrop">
<div className="modal restart-modal" role="alertdialog" aria-modal="true" aria-labelledby="restart-title">
<div className="restart-spinner" aria-hidden="true" />
<h2 id="restart-title">{updating ? "Maestro was updated" : "Maestro is restarting"}</h2>
<p className="restart-detail">
{updating ? "Reloading this page onto the new release…" : attemptText(attempts)}
</p>
<p className="restart-release">{formatRelease(build)}</p>
</div>
</div>
);
}
@@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { keyCaps } from "../../commands.js";
import { groupedCommands } from "../../help.js";
import { groupedCommands } from "./model.js";
import { HelpOverlay } from "./HelpOverlay.js";
afterEach(() => {
+1 -1
View File
@@ -6,7 +6,7 @@ import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
import { groupedCommands } from "../../help.js";
import { groupedCommands } from "./model.js";
export function HelpOverlay() {
const [open, setOpen] = useState(false);
+1
View File
@@ -1,2 +1,3 @@
export { KillDialog, type KillDialogProps } from "./KillDialog.js";
export { HelpOverlay } from "./HelpOverlay.js";
export { groupedCommands } from "./model.js";
+20
View File
@@ -0,0 +1,20 @@
// Command-registry grouping for the help overlay. Moved out of help.ts (whose
// imperative DOM overlay #253 deleted) so HelpOverlay.tsx renders the same
// grouping the vanilla overlay did.
import { publishedCommands, type Command } from "../../commands.js";
/** Published commands bucketed by `group`, in registry order — the order the
* overlay lists them in. */
export function groupedCommands(): { title: string; items: Command[] }[] {
const groups: { title: string; items: Command[] }[] = [];
for (const cmd of publishedCommands()) {
let group = groups.find((g) => g.title === cmd.group);
if (!group) {
group = { title: cmd.group, items: [] };
groups.push(group);
}
group.items.push(cmd);
}
return groups;
}
+382
View File
@@ -0,0 +1,382 @@
// Dev-only design kit (#254), React port of the deleted kit.ts + kit.css.
// Specimen page for the Tailwind token mapping (tailwind.config.ts ->
// src/styles.css) so the visual system can be reviewed as a whole. Loads on
// `?kit` (see index.tsx) and never ships to a production bundle.
import { useState, type ReactNode } from "react";
import { ActivityRail, STATE_WORDS } from "../sidebar/ActivityRail.js";
import { SessionCard, type SessionCardProps } from "../sidebar/SessionCard.js";
import { PaneTitle } from "../layout/PaneTitle.js";
import { PALETTES, useTheme, type Palette } from "../../contexts/ThemeContext.js";
import type { Session } from "../../types.js";
/** The accent each palette lands on, read from styles.css — shown so all six
* are comparable at a glance without switching the live one one at a time. */
const PALETTE_ACCENT: Record<Palette, string> = {
graphite: "oklch(0.92 0.005 256)",
"ice-cyan": "oklch(0.85 0.115 205)",
periwinkle: "oklch(0.75 0.125 275)",
"amber-phosphor": "oklch(0.85 0.16 85)",
ember: "oklch(0.78 0.16 40)",
orchid: "oklch(0.78 0.13 320)",
};
const COLOR_TOKENS = [
"--bg",
"--bg-grid",
"--surface",
"--surface-2",
"--surface-3",
"--border",
"--border-strong",
"--ink",
"--muted",
"--faint",
"--accent",
"--accent-strong",
"--accent-ink",
"--accent-tint",
"--accent-fill",
"--live",
"--danger",
"--danger-tint",
"--warn",
"--term-bg",
] as const;
// Tailwind's content scanner needs each utility to appear as a literal,
// contiguous string in source — a template-built `w-${token}` is invisible
// to it, so every scale below pairs the token label with its whole class name.
const SPACING_TOKENS = [
{ token: "sp-1", widthClass: "w-sp-1" },
{ token: "sp-2", widthClass: "w-sp-2" },
{ token: "sp-3", widthClass: "w-sp-3" },
{ token: "sp-4", widthClass: "w-sp-4" },
{ token: "sp-5", widthClass: "w-sp-5" },
{ token: "sp-6", widthClass: "w-sp-6" },
{ token: "sp-8", widthClass: "w-sp-8" },
] as const;
const RADIUS_TOKENS = [
{ token: "r-1", radiusClass: "rounded-r-1" },
{ token: "r-2", radiusClass: "rounded-r-2" },
{ token: "r-3", radiusClass: "rounded-r-3" },
{ token: "r-pill", radiusClass: "rounded-r-pill" },
] as const;
const FONT_SIZE_TOKENS = [
{ token: "fs-11", sizeClass: "text-fs-11" },
{ token: "fs-12", sizeClass: "text-fs-12" },
{ token: "fs-13", sizeClass: "text-fs-13" },
{ token: "fs-14", sizeClass: "text-fs-14" },
{ token: "fs-15", sizeClass: "text-fs-15" },
{ token: "fs-17", sizeClass: "text-fs-17" },
{ token: "fs-20", sizeClass: "text-fs-20" },
] as const;
const Z_INDEX_TOKENS = [
{ name: "z-sticky", value: 10 },
{ name: "z-backdrop", value: 40 },
{ name: "z-modal", value: 50 },
{ name: "z-toast", value: 60 },
] as const;
function Section({
title,
note,
children,
}: {
title: string;
note?: string;
children: ReactNode;
}) {
return (
<section className="border-t border-border py-sp-6 first:border-t-0 first:pt-0">
<h2 className="text-fs-17 font-semibold text-ink">{title}</h2>
{note && <p className="mt-sp-1 max-w-[70ch] text-fs-12 text-muted">{note}</p>}
<div className="mt-sp-4 flex flex-wrap gap-sp-4">{children}</div>
</section>
);
}
function Specimen({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col items-start gap-sp-2">
<div className="flex min-h-[48px] items-center rounded-r-2 border border-border bg-surface p-sp-3">
{children}
</div>
<span className="font-mono text-fs-11 text-faint">{label}</span>
</div>
);
}
function fakeSession(overrides: Partial<Session>): Session {
return {
id: "00000000-0000-0000-0000-000000000000",
cwd: "/home/dev/maestro",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: new Date().toISOString(),
activity_since: new Date(Date.now() - 4 * 60_000).toISOString(),
...overrides,
};
}
const NOOP_CARD_HANDLERS: Omit<SessionCardProps, "session" | "depth" | "now" | "highlighted" | "active" | "open" | "previewing" | "renaming"> = {
onSelect: () => {},
onOpenMenu: () => {},
onSpawnSimilar: () => {},
onKill: () => {},
onRenameCommit: () => {},
onRenameCancel: () => {},
};
const CARD_SPECIMENS: Array<{ label: string; props: Omit<SessionCardProps, keyof typeof NOOP_CARD_HANDLERS> }> = [
{
label: "idle",
props: {
session: fakeSession({ command: "bash" }),
depth: 0,
now: Date.now(),
highlighted: false,
active: false,
open: false,
previewing: false,
renaming: false,
},
},
{
label: "active, focused",
props: {
session: fakeSession({ activity: "Active", command: "vite dev" }),
depth: 0,
now: Date.now(),
highlighted: false,
active: true,
open: true,
previewing: false,
renaming: false,
},
},
{
label: "awaiting-input",
props: {
session: fakeSession({ activity: "AwaitingInput", activity_since: new Date(Date.now() - 12_000).toISOString() }),
depth: 0,
now: Date.now(),
highlighted: false,
active: false,
open: false,
previewing: false,
renaming: false,
},
},
{
label: "error",
props: {
session: fakeSession({ activity: "Error", activity_detail: "tests failing", activity_since: new Date(Date.now() - 7_200_000).toISOString() }),
depth: 0,
now: Date.now(),
highlighted: false,
active: false,
open: false,
previewing: false,
renaming: false,
},
},
{
label: "previewing",
props: {
session: fakeSession({ command: "opencode" }),
depth: 0,
now: Date.now(),
highlighted: false,
active: false,
open: false,
previewing: true,
renaming: false,
},
},
{
label: "exited",
props: {
session: fakeSession({ status: "Dead", command: "btop" }),
depth: 0,
now: Date.now(),
highlighted: false,
active: false,
open: false,
previewing: false,
renaming: false,
},
},
];
const ACTIVITY_STATES = Object.keys(STATE_WORDS) as Array<keyof typeof STATE_WORDS>;
export function DesignKit() {
const { theme, palette, toggleTheme, setPalette } = useTheme();
// "open" is deliberately display:none in the real app (.mv-pane's rule,
// styles.css) — a streaming pane already shows it's connected — so start on
// a state the kit page can actually show.
const [attachState, setAttachState] = useState<"connecting" | "open" | "closed">("connecting");
return (
// #root is a fixed-height, overflow-hidden flex column (main.css) built for
// the app shell's own internal scrolling — this page needs its own scroll
// container to match, or everything past the first viewport is clipped.
<div className="h-full overflow-y-auto bg-bg text-ink">
<header className="flex items-center justify-between border-b border-border px-sp-4 py-sp-3">
<h1 className="text-fs-20 font-semibold">
maestro<span className="text-accent">_</span> · design kit
</h1>
<div className="flex items-center gap-sp-2">
<button type="button" className="btn btn-ghost" onClick={toggleTheme}>
{theme === "dark" ? "☀ light" : "☾ dark"}
</button>
</div>
</header>
<main className="mx-auto max-w-5xl px-sp-4 py-sp-4">
<Section
title="Colour palettes"
note="6 palettes, picked via data-palette on <html> through ThemeContext. Switching one recolours every token below live."
>
{PALETTES.map((p) => (
<button
key={p}
type="button"
onClick={() => setPalette(p)}
className={`flex flex-col items-center gap-sp-1 rounded-r-2 border p-sp-2 ${
p === palette ? "border-accent" : "border-border"
}`}
>
<span
className="h-sp-6 w-sp-8 rounded-r-1"
style={{ background: PALETTE_ACCENT[p] }}
/>
<span className="font-mono text-fs-11 text-muted">{p}</span>
</button>
))}
</Section>
<Section title="Colour tokens" note="Current palette + theme, applied through var(--token).">
{COLOR_TOKENS.map((t) => (
<div key={t} className="flex flex-col items-start gap-sp-1">
<div
className="h-sp-8 w-sp-8 rounded-r-2 border border-border"
style={{ background: `var(${t})` }}
/>
<span className="font-mono text-fs-11 text-faint">{t}</span>
</div>
))}
</Section>
<Section title="Typography" note="fontSize -> var(--fs-N), from tailwind.config.ts.">
{FONT_SIZE_TOKENS.map(({ token, sizeClass }) => (
<Specimen key={token} label={token}>
<span className={`font-mono ${sizeClass}`}>Sessions</span>
</Specimen>
))}
</Section>
<Section title="Spacing scale" note="spacing -> var(--sp-N).">
{SPACING_TOKENS.map(({ token, widthClass }) => (
<div key={token} className="flex flex-col items-start gap-sp-1">
<div className={`h-sp-3 bg-accent ${widthClass}`} />
<span className="font-mono text-fs-11 text-faint">{token}</span>
</div>
))}
</Section>
<Section title="Border radii" note="borderRadius -> var(--r-N).">
{RADIUS_TOKENS.map(({ token, radiusClass }) => (
<div key={token} className="flex flex-col items-start gap-sp-1">
<div className={`h-sp-6 w-sp-8 border-2 border-accent ${radiusClass}`} />
<span className="font-mono text-fs-11 text-faint">{token}</span>
</div>
))}
</Section>
<Section title="Z-index scale" note="zIndex -> var(--z-*).">
{Z_INDEX_TOKENS.map((z) => (
<div key={z.name} className="flex items-baseline gap-sp-2 font-mono text-fs-12">
<span className="text-muted">{z.name}</span>
<span className="text-ink">{z.value}</span>
</div>
))}
</Section>
<Section title="Buttons" note="Terminal-native bracket chrome — .btn / .btn-primary / .btn-ghost / .btn-icon / .btn-danger.">
<Specimen label="btn">
<button type="button" className="btn">Cancel</button>
</Specimen>
<Specimen label="btn-primary">
<button type="button" className="btn btn-primary">+ Spawn</button>
</Specimen>
<Specimen label="btn-ghost">
<button type="button" className="btn btn-ghost">Dismiss</button>
</Specimen>
<Specimen label="btn-icon">
<button type="button" className="btn btn-icon">✕</button>
</Specimen>
<Specimen label="btn-danger">
<button type="button" className="btn btn-danger">Kill</button>
</Specimen>
<Specimen label="btn:disabled">
<button type="button" className="btn btn-primary" disabled>+ Spawn</button>
</Specimen>
</Section>
<Section title="Activity rail" note="ActivityRail.tsx — one state per session card, colour-coded and never colour-only (title/aria-label carry the word).">
{ACTIVITY_STATES.map((slug) => (
<Specimen key={slug} label={`${slug} · ${STATE_WORDS[slug]}`}>
<div className="relative h-[32px] w-[24px] overflow-hidden rounded-r-1 bg-surface-2">
<ActivityRail activity={slug} />
</div>
</Specimen>
))}
</Section>
<Section title="Session cards" note="SessionCard.tsx, docs/SIDEBAR.md states.">
<div className="w-full max-w-sm divide-y divide-border rounded-r-2 border border-border bg-surface-2">
{CARD_SPECIMENS.map(({ label, props }) => (
<div key={label} className="relative">
<SessionCard {...props} {...NOOP_CARD_HANDLERS} />
</div>
))}
</div>
</Section>
<Section title="Terminal chrome" note="attach-header + attach-body, as worn by TerminalPane/StructuredPane.">
<div className="w-full max-w-lg overflow-hidden rounded-r-2 border border-border">
<div className="attach-header">
<PaneTitle
sessionId="11111111-2222-3333-4444-555555555555"
session={fakeSession({ command: "claude" })}
/>
<span className="flex-1" />
<button
type="button"
className="attach-state"
data-state={attachState}
onClick={() =>
setAttachState((s) => (s === "open" ? "connecting" : s === "connecting" ? "closed" : "open"))
}
title="Click to cycle"
>
{attachState}
</button>
<button type="button" className="btn btn-icon disconnect" title="Disconnect">
⏻
</button>
</div>
<div className="attach-body h-[120px] items-start">
<span className="font-mono text-fs-13 text-muted">$ pnpm test</span>
</div>
</div>
</Section>
</main>
</div>
);
}
+2 -2
View File
@@ -2,8 +2,8 @@
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../catalog.js";
import { TOGGLE_DRAWER_EVENT } from "../../drawer.js";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../uiEvents.js";
import { TOGGLE_DRAWER_EVENT } from "../../uiEvents.js";
import { Drawer } from "./Drawer.js";
/** Force the mobile media query on, whatever happy-dom's viewport says
+1 -2
View File
@@ -10,8 +10,7 @@
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../catalog.js";
import { TOGGLE_DRAWER_EVENT } from "../../drawer.js";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT, TOGGLE_DRAWER_EVENT } from "../../uiEvents.js";
export interface DrawerProps {
sidebar: ReactNode;
+38 -4
View File
@@ -4,17 +4,26 @@
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { useStore } from "zustand";
import { activeTab, type Dir, type Node } from "../../layout.js";
import { usePreview } from "../../hooks/usePreview.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
import type { Session, Uuid } from "../../types.js";
import { PaneContainer } from "./PaneContainer.js";
import { PaneContainer, type PaneContainerHandle } from "./PaneContainer.js";
import { PaneTitle } from "./PaneTitle.js";
import { TabBar } from "./TabBar.js";
/** Reaching the focused pane from outside the tree — what the shell's
* leader chords need (focus.toggle, the literal-byte escape hatches). */
export interface MultiViewHandle {
/** Hand the keyboard to the focused pane's terminal (or composer). */
focusPane(): void;
/** Type into the focused pane. A structured pane has no PTY and ignores it. */
sendInput(data: string): void;
}
export interface MultiViewProps {
store: LayoutStoreApi;
/** Live sessions, keyed by id — drives pane titles and (once known) boot
@@ -24,9 +33,26 @@ export interface MultiViewProps {
emptyState?: ReactNode;
}
export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
export const MultiView = forwardRef<MultiViewHandle, MultiViewProps>(function MultiView(
{ store, sessions, onDisconnect, emptyState },
ref,
) {
const layout = useStore(store, (s: LayoutStore) => s.layout);
const tab = activeTab(layout);
// Live pane handles, keyed by leaf. Read at call time (not render time) so
// the chords always reach whatever is focused right now.
const panes = useRef(new Map<string, PaneContainerHandle>()).current;
const focusedLeafRef = useRef<string | null>(null);
focusedLeafRef.current = tab.focusedLeafId;
useImperativeHandle(
ref,
() => ({
focusPane: () => panes.get(focusedLeafRef.current ?? "")?.focusTerminal(),
sendInput: (data: string) => panes.get(focusedLeafRef.current ?? "")?.sendInput(data),
}),
[panes],
);
const { previewedSessionId, commit: commitPreview } = usePreview({ store });
const isPreviewing = previewedSessionId !== null;
@@ -45,6 +71,7 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
<SplitNode
node={tab.root}
tab={tab}
panes={panes}
store={store}
sessions={sessions}
onDisconnect={onDisconnect}
@@ -69,10 +96,12 @@ export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiVi
</div>
</div>
);
}
});
interface TreeProps {
tab: { id: string; focusedLeafId: string | null };
/** Pane handles by leaf id — each `Pane` registers and unregisters its own. */
panes: Map<string, PaneContainerHandle>;
store: LayoutStoreApi;
sessions?: ReadonlyMap<Uuid, Session>;
onDisconnect?: (sessionId: Uuid) => void;
@@ -145,6 +174,7 @@ function SplitContainer({ node, ...rest }: TreeProps & { node: Extract<Node, { k
function Pane({
leaf,
isFocused,
panes,
store,
sessions,
onDisconnect,
@@ -221,6 +251,10 @@ function Pane({
<div className="mv-pane-host min-h-0 flex-1">
{leaf.sessionId ? (
<PaneContainer
ref={(handle) => {
if (handle) panes.set(leaf.id, handle);
else panes.delete(leaf.id);
}}
sessionId={leaf.sessionId}
session={session}
focus={isFocused && previewedSessionId === null}
@@ -1,64 +0,0 @@
// Right-click menu on a session card (docs/SIDEBAR.md): rename, spawn similar,
// kill. Rendered into <body> so a scrolling sidebar cannot clip it.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
export interface ContextMenuItem {
label: string;
danger?: boolean;
onSelect(): void;
}
export interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose(): void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onDown = (e: Event) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onDown, true);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onDown, true);
};
}, [onClose]);
return createPortal(
<div
ref={ref}
role="menu"
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
style={{ left: x, top: y }}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
className={`block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
item.danger ? "text-danger" : "text-ink"
}`}
onClick={() => {
onClose();
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>,
document.body,
);
}
@@ -1,63 +0,0 @@
// docs/SIDEBAR.md "Killing a session": every kill confirms first, and the
// confirmation names what the card last saw in the way — the daemon refuses an
// unforced kill over such a tree, and saying so beforehand is cheaper than the
// refusal. Cancel holds the focus so a stray Enter kills nothing.
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import type { InTheWay } from "../../types.js";
import { formatInTheWay } from "../../types.js";
export interface KillConfirmProps {
name: string;
blockers: InTheWay | null;
/** Set when the daemon already refused an unforced kill: this pass forces. */
force?: boolean;
onConfirm(): void;
onCancel(): void;
}
export function KillConfirm(props: KillConfirmProps) {
const cancel = useRef<HTMLButtonElement>(null);
useEffect(() => cancel.current?.focus(), []);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={props.force ? "Force kill session" : "Kill session"}
className="kill-confirm fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onKeyDown={(e) => {
if (e.key === "Escape") props.onCancel();
}}
>
<div className="min-w-[280px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
<p className="text-fs-13 text-ink">
{props.force ? "Force kill" : "Kill"} <strong>{props.name}</strong>?
</p>
{props.blockers && (
<p className="mt-sp-2 text-fs-12 text-warn">{formatInTheWay(props.blockers)}</p>
)}
<div className="mt-sp-3 flex justify-end gap-sp-2">
<button
ref={cancel}
type="button"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
onClick={props.onCancel}
>
Cancel
</button>
<button
type="button"
className="rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink"
onClick={props.onConfirm}
>
{props.force ? "Force kill" : "Kill"}
</button>
</div>
</div>
</div>,
document.body,
);
}
+37 -7
View File
@@ -10,9 +10,10 @@ import type { CatalogStore, CatalogStoreApi } from "../../stores/catalogStore.js
import type { InTheWay, Session } from "../../types.js";
import { sessionBlockers, sessionName } from "../../types.js";
import { buildTree, type TreeNode } from "../../tree.js";
import { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT } from "../../uiEvents.js";
import { ContextMenu, type ContextMenuItem } from "../common/ContextMenu.js";
import { FolderNode } from "./FolderNode.js";
import { KillConfirm } from "./KillConfirm.js";
import { KillDialog } from "../dialogs/KillDialog.js";
import { SessionCard } from "./SessionCard.js";
import { SidebarSearch } from "./SidebarSearch.js";
import { filterSessions, firstRowUnder, navRows, sessionKey } from "./model.js";
@@ -58,6 +59,8 @@ interface KillState {
session: Session;
blockers: InTheWay | null;
force: boolean;
/** The daemon's refusal message, once an unforced kill has been refused. */
reason?: string;
}
export function Sidebar(props: SidebarProps) {
@@ -158,6 +161,27 @@ export function Sidebar(props: SidebarProps) {
return sessions.find((s) => s.id === row.id);
}, [rows, navKey, sessions]);
// The command bar's action page raises these rather than reimplementing
// either flow (docs/COMMAND-BAR.md): a kill from the bar goes through the
// same confirm-and-blockers dialog the card's ✕ does, and a rename from the
// bar opens the card's own inline field.
useEffect(() => {
const onKillRequest = (e: Event) => {
const s = (e as CustomEvent<Session>).detail;
if (s) beginKill(s);
};
const onRenameRequest = (e: Event) => {
const s = (e as CustomEvent<Session>).detail;
if (s) setRenamingId(s.id);
};
document.addEventListener(KILL_SESSION_EVENT, onKillRequest);
document.addEventListener(RENAME_SESSION_EVENT, onRenameRequest);
return () => {
document.removeEventListener(KILL_SESSION_EVENT, onKillRequest);
document.removeEventListener(RENAME_SESSION_EVENT, onRenameRequest);
};
}, [beginKill]);
const onKeyDown = (e: React.KeyboardEvent) => {
// The inline rename field owns its own keys; the walk stays out of it.
if (e.target instanceof HTMLInputElement) return;
@@ -296,15 +320,18 @@ export function Sidebar(props: SidebarProps) {
/>
)}
{kill && (
<KillConfirm
<KillDialog
name={sessionName(kill.session)}
blockers={kill.blockers}
force={kill.force}
reason={kill.reason}
onCancel={() => setKill(null)}
onConfirm={() => {
onConfirm={async () => {
const { session: s, force } = kill;
setKill(null);
void killSession(s.id, force).catch((e: unknown) => {
try {
await killSession(s.id, force);
setKill(null);
} catch (e: unknown) {
// The daemon refuses an unforced kill over a worktree holding
// unsaved work; that refusal needs its own prompt
// (docs/WORKTREES.md).
@@ -313,9 +340,12 @@ export function Sidebar(props: SidebarProps) {
session: s,
blockers: e.unsavedWork ?? sessionBlockers(s),
force: true,
reason: e.message,
});
return;
}
});
throw e;
}
}}
/>
)}
+1 -2
View File
@@ -3,6 +3,5 @@ export { SessionCard, type SessionCardProps } from "./SessionCard.js";
export { FolderNode, type FolderNodeProps } from "./FolderNode.js";
export { SidebarSearch, type SidebarSearchProps } from "./SidebarSearch.js";
export { ActivityRail } from "./ActivityRail.js";
export { ContextMenu, type ContextMenuItem } from "./ContextMenu.js";
export { KillConfirm } from "./KillConfirm.js";
export { ContextMenu, type ContextMenuItem } from "../common/ContextMenu.js";
export * from "./model.js";
+1 -1
View File
@@ -3,7 +3,7 @@
// spawnForm.ts's `.adv-group`/`.wt-body`/`.wt-mode` markup.
import type { BranchInfo } from "../../types.js";
import type { WorktreeBranchMode } from "../../spawnForm.js";
import type { WorktreeBranchMode } from "./model.js";
export interface BranchPickerProps {
enabled: boolean;
+1 -1
View File
@@ -42,7 +42,7 @@ import {
type SpawnFormSeed,
type SpawnFormValues,
type WorktreeBranchMode,
} from "../../spawnForm.js";
} from "./model.js";
import { splitCommand, toolIconSvg, TOOL_LABELS } from "../../toolIcon.js";
import { abbreviateCwd } from "../../types.js";
import type { BranchInfo, Project, Session, SpawnPreset } from "../../types.js";
+1 -1
View File
@@ -5,4 +5,4 @@ export { BranchPicker, type BranchPickerProps } from "./BranchPicker.js";
export { useFastForward, type UseFastForwardOptions } from "./useFastForward.js";
// spawnSimilar.ts is pure seed-building logic with no DOM dependency — reused
// as-is rather than re-implemented for React (#248).
export { spawnSimilarSeed, spawnSimilarProjectSeed } from "../../spawnSimilar.js";
export { spawnSimilarSeed, spawnSimilarProjectSeed } from "./spawnSimilar.js";
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildSpawnRequest, type SpawnFormValues } from "./spawnForm.js";
import { buildSpawnRequest, type SpawnFormValues } from "./model.js";
const base: SpawnFormValues = {
command: "claude",
+104
View File
@@ -0,0 +1,104 @@
// Pure spawn-form model, kept when spawnForm.ts's imperative DOM form was
// deleted (#253): the value/seed shapes, the request builder, and the preset
// fallback. SpawnForm.tsx and useFastForward.ts render from these.
import { splitCommand } from "../../toolIcon.js";
import type { CreateSessionRequest, SpawnPreset } from "../../types.js";
export const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
export function supportsStructuredView(command: string): boolean {
const tool = splitCommand(command).tool;
return tool !== null && STRUCTURED_VIEW_TOOLS.has(tool);
}
/** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches (#61). */
export type WorktreeBranchMode = "new" | "existing";
/** Seed values for the spawn form — used by "Spawn similar" to open the modal
* with the same cwd, command, and name as an existing session, and by the
* fast-forward fallback to pre-fill whatever it did resolve. */
export interface SpawnFormSeed {
command?: string;
cwd?: string;
name?: string;
worktreeEnabled?: boolean;
worktreeBranch?: string;
/** Whether the operator typed the branch themselves, rather than it having
* been auto-synced from the session name. Carried separately because the
* value alone cannot distinguish the two. */
worktreeBranchEdited?: boolean;
worktreeBranchMode?: WorktreeBranchMode;
cwdRawVisible?: boolean;
}
/** The form's current values, reported on every change. */
export interface SpawnFormValues {
command: string;
cwd: string;
name: string;
structuredView: boolean;
autoApprove: boolean;
worktreeEnabled: boolean;
worktreeBranch: string;
worktreeBranchEdited: boolean;
/** "new" creates the branch; "existing" checks out a picked one (#61). */
worktreeBranchMode: WorktreeBranchMode;
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
cwdRawVisible?: boolean;
}
export function buildSpawnRequest(
values: SpawnFormValues,
baseRepo: string | null,
): { req: CreateSessionRequest } | { error: string } {
if (!values.cwd) return { error: "Pick a working directory." };
if (!values.command) return { error: "Pick a command (or fill the custom command)." };
const metadata: Record<string, unknown> = {};
const name = values.name.trim();
if (name) metadata.name = name;
const req: CreateSessionRequest = {
cwd: values.cwd,
command: values.command,
metadata,
};
if (values.structuredView && supportsStructuredView(values.command)) {
req.view = "Structured";
}
if (values.autoApprove) {
req.auto_approve = true;
}
if (values.worktreeEnabled) {
const mode = values.worktreeBranchMode ?? "new";
const branch = values.worktreeBranch.trim();
if (!baseRepo) return { error: "Worktree needs a directory inside a git repository." };
if (!branch) {
return { error: mode === "existing" ? "Pick an existing branch." : "Worktree requires a branch name." };
}
req.worktree = {
base_repo: baseRepo,
branch,
// Existing-mode picks must check out as-is; the daemon creates
// anything else with `worktree add -b` (#56/#61).
create_branch: mode !== "existing",
};
}
return { req };
}
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`.
* Exported for the fast-forward spawn's preset fallback (#67). */
export const FALLBACK_PRESETS: SpawnPreset[] = [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "opencode", label: "opencode", command: "opencode" },
{ id: "zsh", label: "Shell", command: "zsh" },
{ id: "custom", label: "Custom…", command: "" },
];
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
import type { Session } from "./types.js";
import type { Session } from "../../types.js";
function session(overrides: Partial<Session> = {}): Session {
return {
@@ -1,8 +1,8 @@
// "Spawn similar" (#28): the seed a cloned session hands to the spawn modal.
// Pure — the caller owns opening the dialog.
import type { SpawnFormSeed } from "./spawnForm.js";
import type { Session } from "./types.js";
import type { SpawnFormSeed } from "./model.js";
import type { Session } from "../../types.js";
/** Clone a session into form values: same project, same command, same
* worktree decision, fresh name.
+1 -1
View File
@@ -8,7 +8,7 @@ import { useCallback } from "react";
import { createSession, fetchNames, getDaemonInfo, projects as fetchProjects } from "../../api.js";
import { buildFastForwardRequest, fastForwardSeed, resolveFastForward } from "../../fastForward.js";
import { FALLBACK_PRESETS, type SpawnFormSeed } from "../../spawnForm.js";
import { FALLBACK_PRESETS, type SpawnFormSeed } from "./model.js";
import type { Project, Session, SpawnPreset } from "../../types.js";
export interface UseFastForwardOptions {
+2 -2
View File
@@ -1,10 +1,10 @@
// A `Diff` content block, rendered as coloured hunks. ACP hands over whole
// `oldText`/`newText`, so the lines and their `+added −removed` counts are
// computed here (structuredDiff.ts) rather than parsed out of a patch.
// computed here (diff.ts) rather than parsed out of a patch.
import { useMemo } from "react";
import { computeDiffLines, diffStats, type DiffLine } from "../../structuredDiff.js";
import { computeDiffLines, diffStats, type DiffLine } from "./diff.js";
import type { AcpDiff } from "../../types.js";
import { Copyable } from "./Copyable.js";
@@ -25,7 +25,7 @@ import {
setStructuredConfig,
uploadStructuredFile,
} from "../../api.js";
import { formatContextTokens, formatElapsed } from "../../structuredRender.js";
import { formatContextTokens, formatElapsed } from "./render.js";
import type {
AcpSessionConfigOption,
StructuredApprovalDecision,
@@ -7,7 +7,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "../../structuredRender.js";
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "./render.js";
import type {
AcpContentBlock,
AcpPermissionOption,
@@ -35,7 +35,7 @@ import { ToolIcon } from "./ToolIcon.js";
// content rows: user / agent / thought
// ---------------------------------------------------------------------------
/** Model-authored markdown, sanitized by structuredRender.ts before it enters
/** Model-authored markdown, sanitized by render.ts before it enters
* the DOM. Tables get their own scroller and links open out of the app. */
function Prose({ html }: { html: string }) {
const ref = useRef<HTMLDivElement | null>(null);
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { computeDiffLines, diffStats, type DiffLine } from "./structuredDiff.js";
import { computeDiffLines, diffStats, type DiffLine } from "./diff.js";
function texts(lines: DiffLine[]): Array<[string, string]> {
return lines.map((l) => [l.kind, l.text]);
+2 -2
View File
@@ -1,7 +1,7 @@
// Pure transcript logic for the structured pane (#246) — the decisions
// docs/STRUCTURED-VIEW.md leaves to a client, kept out of the components so
// they can be tested without a DOM or a socket. Ported from structured.ts's
// private helpers and the parts of structuredRender.ts that were data, not
// private helpers and the parts of the old structuredRender.ts that were data, not
// markup.
import type {
@@ -14,7 +14,7 @@ import type {
TranscriptRowKind,
TurnMeta,
} from "../../types.js";
import { computeDiffLines, diffStats } from "../../structuredDiff.js";
import { computeDiffLines, diffStats } from "./diff.js";
/** Human label behind a `ToolKind`. An unrecognised/absent kind is "Tool" —
* never a blank label. */
+52
View File
@@ -0,0 +1,52 @@
// Pure text helpers for the structured transcript, kept when
// structuredRender.ts's imperative DOM builders were deleted (#253):
// markdown rendering (sanitized) and the elapsed/context formatters.
import { marked, Renderer } from "marked";
import { escapeHtml } from "../../html.js";
/** Small inline renderer retained for compact labels and focused tests. */
export function renderInlineMarkdown(text: string): string {
const escaped = escapeHtml(text);
return escaped
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
}
/** Render assistant output as GFM, then sanitize the complete result before it
* enters the DOM. This gives transcripts proper tables, lists, code blocks and
* links without trusting model-authored HTML or URL attributes. */
export function renderMarkdown(text: string): string {
const renderer = new Renderer();
renderer.html = ({ text: raw }) => escapeHtml(raw);
renderer.image = ({ text: alt }) => escapeHtml(alt ? `[image: ${alt}]` : "[image]");
renderer.link = ({ href, title, tokens }) => {
const label = renderer.parser.parseInline(tokens);
const scheme = /^([a-z][a-z\d+.-]*):/i.exec(href.trim())?.[1]?.toLowerCase();
if (scheme && !["http", "https", "mailto"].includes(scheme)) return label;
const safeTitle = title ? ` title="${escapeHtml(title)}"` : "";
return `<a href="${escapeHtml(href)}"${safeTitle}>${label}</a>`;
};
return marked.parse(text, { async: false, gfm: true, renderer });
}
function compactNumber(value: number): string {
if (Math.abs(value) < 1_000) return String(value);
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
}
export function formatElapsed(milliseconds: number): string {
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
}
export function formatContextTokens(value: number): string {
return `${compactNumber(value)} context`;
}
+1 -1
View File
@@ -92,7 +92,7 @@ export function KeyBar({ armed, onToggleMod, onClearMods, onSend }: KeyBarProps)
<div
role="toolbar"
aria-label="Special keys"
className="flex flex-wrap gap-1 border-t border-white/10 bg-black/20 p-1"
className="keybar-touch flex-wrap gap-1 border-t border-white/10 bg-black/20 p-1"
>
{KEY_DEFS.map((def) => {
if ("mod" in def) {
+5 -1
View File
@@ -347,7 +347,11 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
return (
<div className={className ? `flex h-full flex-col ${className}` : "flex h-full flex-col"}>
<div ref={bodyRef} className="min-h-0 flex-1" />
{/* `attach-body` is what styles.css keys the terminal's own sizing on
(`.attach-body .xterm { width/height: 100% }`) plus the hidden
scrollbar and touch-scroll rules. Without it the terminal measures
its own stale viewport and never grows back to the pane. */}
<div ref={bodyRef} className="attach-body min-h-0 flex-1" />
{keybarActive && (
<KeyBar
armed={keyBar.armed}
-387
View File
@@ -1,387 +0,0 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionPool } from "./connections.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
// the pool reaches the focused connection with them.
const sent: { id: string; data: string }[] = [];
const disposed: string[] = [];
const mounts: { id: string; focus: boolean | undefined }[] = [];
function fakeConn(kind: string, id: string) {
const el = document.createElement("div");
el.tabIndex = -1; // focusable via .focus(), like a real input/terminal
el.dataset.kind = kind;
el.dataset.session = id;
el.dataset.testid = id; // lets a scoped `within(...).getByTestId` find this fake connection
return {
sessionId: id,
kind,
el,
onMount(opts?: { focus?: boolean }) {
mounts.push({ id, focus: opts?.focus });
},
onUnmount() {},
focusTerminal() {},
sendInput(data: string) {
sent.push({ id, data });
},
updateInfo() {},
dispose() {
disposed.push(`${kind}:${id}`);
},
};
}
vi.mock("./attach.js", () => ({
createAttachConnection: (id: string) => fakeConn("attach", id),
}));
vi.mock("./structured.js", () => ({
createStructuredConnection: (id: string) => fakeConn("structured", id),
}));
afterEach(() => {
vi.unstubAllGlobals();
sent.length = 0;
disposed.length = 0;
mounts.length = 0;
});
/** Minimal fixture for the `CatalogState` a `CatalogStore` broadcasts. */
function session(id: string, view?: "Terminal" | "Structured"): Session {
return {
id,
cwd: "/repo",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
view,
};
}
/** A `CatalogStore` an id can be pushed into on demand, mirroring
* events.test.ts's own injectable-socket pattern. */
class FakeCatalogSocket {
onmessage: ((ev: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
close(): void {}
push(sessions: Session[]): void {
this.onmessage?.({ data: JSON.stringify({ type: "Snapshot", sessions }) });
}
}
function fakeStore(): { store: CatalogStore; socket: FakeCatalogSocket } {
const socket = new FakeCatalogSocket();
const store = new CatalogStore({ socket: () => socket as unknown as WebSocket });
store.start();
return { store, socket };
}
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
function mockFetch() {
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("ConnectionPool.setFocus", () => {
it("reports the new session focused, none unfocused, the first time", () => {
const fetchMock = mockFetch();
const pool = new ConnectionPool();
pool.setFocus(A);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${A}/focus`);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({ focused: true });
});
it("unfocuses the previous session and focuses the new one on a switch", () => {
const fetchMock = mockFetch();
const pool = new ConnectionPool();
pool.setFocus(A);
fetchMock.mockClear();
pool.setFocus(B);
expect(fetchMock).toHaveBeenCalledTimes(2);
const bodies = fetchMock.mock.calls.map(([url, init]: unknown[]) => [
url,
JSON.parse((init as RequestInit).body as string),
]);
expect(bodies).toContainEqual([`/api/sessions/${A}/focus`, { focused: false }]);
expect(bodies).toContainEqual([`/api/sessions/${B}/focus`, { focused: true }]);
});
it("reports unfocused when focus clears to null", () => {
const fetchMock = mockFetch();
const pool = new ConnectionPool();
pool.setFocus(A);
fetchMock.mockClear();
pool.setFocus(null);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${A}/focus`);
expect(JSON.parse(init.body as string)).toEqual({ focused: false });
});
it("is a no-op when the focused session doesn't change", () => {
const fetchMock = mockFetch();
const pool = new ConnectionPool();
pool.setFocus(A);
fetchMock.mockClear();
pool.setFocus(A);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not let a fetch rejection break the pool", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const pool = new ConnectionPool();
expect(() => pool.setFocus(A)).not.toThrow();
// Let the rejected promise's `.catch` settle before the test ends.
await Promise.resolve();
await Promise.resolve();
});
});
describe("ConnectionPool.sendToFocused", () => {
it("types the bytes into the focused session's PTY", () => {
mockFetch();
sent.length = 0;
const pool = new ConnectionPool();
const pane = document.createElement("div");
pool.mount(A, pane);
pool.setFocus(A);
expect(pool.sendToFocused("\x0b")).toBe(true);
expect(sent).toEqual([{ id: A, data: "\x0b" }]);
});
it("is a no-op with nothing focused", () => {
mockFetch();
sent.length = 0;
const pool = new ConnectionPool();
expect(pool.sendToFocused("\x0b")).toBe(false);
expect(sent).toEqual([]);
});
});
// A deep link or restored layout can `mount()` a session before the catalog
// snapshot names its view — the daemon defaults an absent `view` to
// `"Terminal"`, but so does an *unknown* session, and only one of those is
// actually resolved (#140 review).
describe("ConnectionPool: view resolution races", () => {
it("replaces a wrongly-typed connection once the catalog resolves it, without waiting for another mount()", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
pool.mount(A, pane); // no info: deep-link-before-snapshot race
expect(disposed).toEqual([]);
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
// The fresh connection landed in the same pane nothing re-mounted it into.
expect(pane.children).toHaveLength(1);
});
it("also repairs a wrongly-typed connection that is parked, not mounted, without evicting its other parked siblings", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const hidden = document.body.lastElementChild as HTMLElement; // this pool's own cache
pool.mount(A, document.createElement("div"));
pool.mount(B, document.createElement("div"));
pool.unmount(A);
pool.unmount(B);
disposed.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
// B's own element is still parked — A's repair didn't wipe its siblings
// out of the shared hidden cache.
expect(within(hidden).getByTestId(B)).toBeInTheDocument();
expect(within(hidden).getByTestId(A)).toHaveAttribute("data-kind", "structured");
});
it("does not replace a connection whose resolved view already matches", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual([]);
});
it("does not downgrade an already-resolved connection when a later mount() call has no info", () => {
mockFetch();
const pool = new ConnectionPool();
const pane = document.createElement("div");
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
pool.mount(A, pane); // e.g. a re-render before the catalog answers again
expect(disposed).toEqual([]);
});
it("does not treat a session missing from the catalog as Terminal and churn a live connection", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
// `A` is absent from this snapshot — main.ts's own subscription handles
// closing the pane; the pool must not race it by "fixing" the view first.
socket.push([session(B, "Terminal")]);
expect(disposed).toEqual([]);
});
it("re-focuses the fresh connection when the keyboard was actually inside the replaced pane", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
const conn = within(pane).getByTestId(A);
conn.focus();
expect(conn).toHaveFocus();
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: true }]);
pane.remove();
});
it("does not steal focus from a same-tab sidebar preview even though focusedId still names the previous pane", () => {
// docs/PREVIEW.md: previewing a session already open in the current tab
// moves the keyboard into the sidebar without ever calling setFocus, so
// `focusedId` keeps naming the pane the operator was in before — but the
// DOM disagrees, and the DOM is what must decide.
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
within(pane).getByTestId(A).focus();
const sidebarInput = document.createElement("input");
document.body.appendChild(sidebarInput);
sidebarInput.focus();
expect(sidebarInput).toHaveFocus();
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: false }]);
pane.remove();
sidebarInput.remove();
});
it("counts focus on a descendant of the pane's element, not just the element itself", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
const textarea = document.createElement("textarea");
within(pane).getByTestId(A).appendChild(textarea);
textarea.focus();
expect(textarea).toHaveFocus();
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(mounts).toEqual([{ id: A, focus: true }]);
pane.remove();
});
it("treats document.body (nothing meaningfully focused) as no active descendant", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
(document.activeElement as HTMLElement | null)?.blur();
expect(document.body).toHaveFocus();
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(mounts).toEqual([{ id: A, focus: false }]);
pane.remove();
});
it("does not steal focus for a merely-visible pane that was never actually focused", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const paneA = document.createElement("div");
const paneB = document.createElement("div");
pool.mount(A, paneA);
pool.mount(B, paneB);
pool.setFocus(B); // B is the real keyboard focus; A is only visible
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: false }]);
});
it("does not focus a parked (unmounted) connection it repairs", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
pool.mount(A, pane);
pool.setFocus(A);
pool.unmount(A); // parked — no longer visible, `focusedId` still names it
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([]); // parked repair calls onUnmount, never onMount
});
});
-298
View File
@@ -1,298 +0,0 @@
import { createAttachConnection } from "./attach.js";
import type { AttachInfo } from "./paneTitle.js";
import type { PaneConnection } from "./pane.js";
import { createStructuredConnection } from "./structured.js";
import { setSessionFocus } from "./api.js";
import type { CatalogState, CatalogStore } from "./events.js";
import { sessionName, type SessionView, type Uuid } from "./types.js";
/**
* How many *parked* (off-screen but still-connected) sessions to keep warm
* beyond whatever is currently visible. Visible panes are never counted or
* evicted — only this background buffer is bounded, so the browser doesn't hold
* an unbounded fan of open sockets. Switching back to a recently-viewed session
* is instant while it's still parked; older ones are evicted (WS closed) once
* the buffer overflows.
*/
const MAX_PARKED = 6;
/** A session's view defaults to `"Terminal"` wherever it isn't carried —
* matching the wire default (`SessionView`'s `#[default]`, types.ts). Only
* meaningful once the session itself is known; an altogether-unknown
* session (no `AttachInfo`/`Session` at all) is a distinct, un-resolved
* state callers must check for separately, not "Terminal". */
function resolveView(view: SessionView | undefined): SessionView {
return view ?? "Terminal";
}
export interface PoolOptions {
/** Fired when the focused session changes (drives sidebar highlight + HUD). */
onFocusChange?: (id: Uuid | null) => void;
/** Fired when a view's explicit disconnect control is used; the embedder
* decides how to update the layout/route (then typically calls `close`). */
onRequestClose?: (id: Uuid) => void;
/** Fired when a connection's socket closes on its own (session ended/dropped),
* not via an intentional close/eviction. The embedder closes the pane. */
onClosed?: (id: Uuid) => void;
/** Forwarded to each connection: should a dropped socket be retried? */
shouldReconnect?: (id: Uuid, code: number) => boolean;
/** Threaded through to a structured connection so its rail can read live
* worktree/git-status data (#140). Terminal connections don't need it. */
store?: CatalogStore;
}
/**
* Owns the set of live attach connections, decoupled from the router/UI.
*
* Unlike the old single-active pool, several connections can be **mounted**
* (visible) at once — one per leaf of the active tab's layout tree. The shell
* reconciles the visible set with {@link setVisible}; sessions no longer shown
* are **parked** in a hidden cache (still streaming) and bounded by an LRU. A
* connection only dies on eviction, an explicit {@link close}, or {@link dispose}.
*/
export class ConnectionPool {
/** All live connections. Insertion order doubles as LRU: oldest first. */
private readonly cache = new Map<Uuid, PaneConnection>();
/** The view each cached connection was actually created for — a deep link
* or a restored layout can `mount()` a session before the catalog
* snapshot names its view, defaulting it to `"Terminal"`; this is what
* lets a later reconciliation notice the resolved view disagrees (#140
* review). */
private readonly connViews = new Map<Uuid, SessionView>();
/** Sessions currently mounted into a visible pane (never evicted). */
private readonly mounted = new Set<Uuid>();
private readonly hidden: HTMLElement;
private focusedId: Uuid | null = null;
private readonly opts: PoolOptions;
private readonly unsubscribeStore?: () => void;
constructor(opts: PoolOptions = {}) {
this.opts = opts;
// Off-screen home for parked, still-connected terminals.
this.hidden = document.createElement("div");
this.hidden.className = "conn-cache";
this.hidden.setAttribute("aria-hidden", "true");
document.body.appendChild(this.hidden);
// A session `mount()`ed before its catalog entry exists gets a
// `"Terminal"` connection by default; once the snapshot actually names
// the view, fix any connection that guessed wrong — nothing else
// re-`mount()`s an already-open pane just because the catalog updated.
this.unsubscribeStore = opts.store?.subscribe((state) => this.reconcileViews(state));
}
/** The focused session, or null when nothing is focused (welcome pane). */
focused(): Uuid | null {
return this.focusedId;
}
/** Back-compat alias for {@link focused} (used by the debug HUD). */
active(): Uuid | null {
return this.focusedId;
}
isMounted(id: Uuid): boolean {
return this.mounted.has(id);
}
/**
* Ensure `id` is live and shown inside `pane`, reusing its connection if one
* exists (refreshing LRU recency). Does not touch other mounted panes.
*/
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
let conn = this.cache.get(id);
if (conn) {
// `info === undefined` means "session not known yet", not "Terminal" —
// never downgrade a resolved connection just because a later caller
// couldn't resolve the session. Only a *resolved* view that disagrees
// with what this connection was built for forces a replace.
if (info !== undefined && resolveView(info.view) !== this.connViews.get(id)) {
conn = this.replace(id, info);
} else {
this.cache.delete(id);
this.cache.set(id, conn); // LRU refresh
if (info) conn.updateInfo(info);
}
} else {
conn = this.create(id, info);
this.cache.set(id, conn);
}
if (conn.el.parentElement !== pane) {
pane.replaceChildren(conn.el);
conn.onMount(opts);
} else if (!this.mounted.has(id)) {
conn.onMount(opts);
}
this.mounted.add(id);
this.evict();
}
private create(id: Uuid, info?: AttachInfo): PaneConnection {
const view = resolveView(info?.view);
this.connViews.set(id, view);
const connOpts = {
onRequestClose: (sid: Uuid) => this.opts.onRequestClose?.(sid),
onClosed: (sid: Uuid) => this.opts.onClosed?.(sid),
shouldReconnect: (sid: Uuid, code: number) => this.opts.shouldReconnect?.(sid, code) ?? false,
};
return view === "Structured"
? createStructuredConnection(id, connOpts, info, this.opts.store)
: createAttachConnection(id, connOpts, info);
}
/** Dispose whatever is cached for `id` and cache a freshly created
* connection in its place. Does not touch the DOM or `mounted` — callers
* place the new element themselves. */
private replace(id: Uuid, info?: AttachInfo): PaneConnection {
this.cache.get(id)?.dispose();
const fresh = this.create(id, info);
this.cache.set(id, fresh);
return fresh;
}
/** Proactively fix any cached connection whose view no longer matches the
* catalog — the only path that repairs a pane nothing will `mount()`
* again on its own (parked, or mounted but not currently being
* re-rendered by anything else). A session absent from `state` (not yet
* known, or removed) is left alone — that's `main.ts`'s
* `closePane`/eviction concern, not a view mismatch. */
private reconcileViews(state: CatalogState): void {
for (const id of [...this.cache.keys()]) {
const session = state.sessions.get(id);
if (session === undefined || resolveView(session.view) === this.connViews.get(id)) continue;
const existing = this.cache.get(id)!;
const parent = existing.el.parentElement;
const wasMounted = this.mounted.has(id);
// Capture before anything below touches the DOM: dispose()/el.remove()
// detach `existing.el`, and once a focused descendant is detached the
// browser moves `document.activeElement` to `<body>` — too late to
// tell whether the keyboard was actually in this pane.
const hadDomFocus = existing.el.contains(document.activeElement);
const info: AttachInfo = { name: sessionName(session), command: session.command, view: session.view };
const fresh = this.replace(id, info);
existing.el.remove(); // don't rely on dispose() to clear the DOM
if (parent && parent !== this.hidden) {
parent.replaceChildren(fresh.el);
} else {
// Parked: `this.hidden` holds every parked connection's element as
// siblings — append, never replaceChildren, or they'd all vanish.
this.hidden.appendChild(fresh.el);
}
if (wasMounted) {
// `focusedId` names the session the pool was last told to focus —
// but a same-tab sidebar preview moves the keyboard into the
// sidebar without clearing it (multiview.ts skips `setFocus` while
// previewing), so `focusedId` alone can point at a pane the
// keyboard already left. Only re-focus when the DOM agrees it
// never did.
fresh.onMount({ focus: this.focusedId === id && hadDomFocus });
} else {
fresh.onUnmount();
}
}
}
/** Park `id` back into the hidden cache (still connected) and stop showing it. */
unmount(id: Uuid): void {
if (!this.mounted.delete(id)) return;
const conn = this.cache.get(id);
if (conn) {
this.hidden.appendChild(conn.el); // stays alive, just off-screen
conn.onUnmount();
}
}
/**
* Reconcile the visible set to exactly `entries` (in render order): park any
* mounted session not listed, mount/refresh each listed one in its pane.
* `focus: false` shows the panes without taking the keyboard (preview).
*/
setVisible(
entries: { id: Uuid; pane: HTMLElement; info?: AttachInfo }[],
opts?: { focus?: boolean },
): void {
const next = new Set(entries.map((e) => e.id));
for (const id of [...this.mounted]) {
if (!next.has(id)) this.unmount(id);
}
for (const e of entries) this.mount(e.id, e.pane, e.info, opts);
}
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
* `AwaitingInput` auto-clear) — the sidebar highlight never calls this,
* only a pane actually becoming the visible view. Best-effort: a failed
* report only delays the glow clearing. */
setFocus(id: Uuid | null): void {
if (id === this.focusedId) return;
const previous = this.focusedId;
this.focusedId = id;
if (previous !== null) void setSessionFocus(previous, false).catch(() => {});
if (id !== null) void setSessionFocus(id, true).catch(() => {});
this.opts.onFocusChange?.(id);
}
/** Compat shim: show `id` alone in `pane` and focus it; park everything else. */
activate(id: Uuid, pane: HTMLElement, info?: AttachInfo): void {
this.setVisible([{ id, pane, info }]);
this.setFocus(id);
}
/** Compat shim: park all visible panes and clear focus (e.g. welcome pane). */
parkActive(): void {
for (const id of [...this.mounted]) this.unmount(id);
this.setFocus(null);
}
/** Put the keyboard back into the focused session's terminal. No-op when
* nothing is focused (welcome pane) or its connection is gone. */
focusTerminal(): void {
if (this.focusedId === null) return;
this.cache.get(this.focusedId)?.focusTerminal();
}
/** Type raw bytes into the focused session's PTY. Returns false when there is
* nothing focused, or its connection is gone — the leader's literal Ctrl-K
* is a no-op over the welcome pane rather than an error
* (docs/COMMAND-BAR.md). */
sendToFocused(data: string): boolean {
if (this.focusedId === null) return false;
const conn = this.cache.get(this.focusedId);
if (!conn) return false;
conn.sendInput(data);
return true;
}
/** Fully disconnect + forget a session. */
close(id: Uuid): void {
const conn = this.cache.get(id);
if (!conn) return;
conn.dispose();
this.cache.delete(id);
this.connViews.delete(id);
this.mounted.delete(id);
if (this.focusedId === id) this.setFocus(null);
}
dispose(): void {
this.unsubscribeStore?.();
for (const conn of this.cache.values()) conn.dispose();
this.cache.clear();
this.connViews.clear();
this.mounted.clear();
this.hidden.remove();
}
/** Drop least-recently-viewed *parked* connections beyond the buffer cap. */
private evict(): void {
while (this.cache.size - this.mounted.size > MAX_PARKED) {
let victim: Uuid | null = null;
for (const id of this.cache.keys()) {
if (!this.mounted.has(id)) {
victim = id;
break;
}
}
if (victim === null) break;
this.close(victim);
}
}
}
-58
View File
@@ -1,58 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { closeContextMenu, openContextMenu } from "./contextMenu.js";
afterEach(() => {
closeContextMenu();
document.body.replaceChildren();
});
function labels(): string[] {
return screen.getAllByRole("menuitem").map((b) => b.textContent ?? "");
}
describe("context menu (#125)", () => {
it("renders one item per entry, in order", () => {
openContextMenu(10, 20, [
{ label: "Rename…", onSelect: () => {} },
{ label: "Kill session", onSelect: () => {}, danger: true },
]);
expect(labels()).toEqual(["Rename…", "Kill session"]);
expect(screen.getByRole("menuitem", { name: "Kill session" })).toHaveClass("is-danger");
});
it("runs the selected entry and closes", () => {
const chosen = vi.fn();
openContextMenu(0, 0, [{ label: "Rename…", onSelect: chosen }]);
fireEvent.click(screen.getByRole("menuitem", { name: "Rename…" }));
expect(chosen).toHaveBeenCalledOnce();
expect(screen.queryByRole("menu")).toBeNull();
});
it("opening a second menu replaces the first", () => {
openContextMenu(0, 0, [{ label: "first", onSelect: () => {} }]);
openContextMenu(0, 0, [{ label: "second", onSelect: () => {} }]);
expect(screen.getAllByRole("menu")).toHaveLength(1);
expect(labels()).toEqual(["second"]);
});
it("closes on Escape and on a press outside it", () => {
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("menu")).toBeNull();
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("menu")).toBeNull();
});
it("stays open for a press inside itself", () => {
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
const menu = screen.getByRole("menu");
fireEvent.pointerDown(menu);
expect(screen.queryByRole("menu")).not.toBeNull();
});
});
-81
View File
@@ -1,81 +0,0 @@
/** Custom right-click menu (#125).
*
* One menu exists at a time: opening a second closes the first. The menu is
* appended to `<body>` rather than to the row it belongs to, so an overflowing
* sidebar cannot clip it.
*/
export interface ContextMenuItem {
label: string;
onSelect: () => void;
/** Renders the entry in the destructive style. */
danger?: boolean;
}
let open: { el: HTMLElement; dispose: () => void } | null = null;
/** Close the menu that is currently up, if any. */
export function closeContextMenu(): void {
open?.dispose();
}
/**
* Open a menu at viewport coordinates `x`/`y`. The menu closes on selection,
* on Escape, on a pointer press anywhere outside it, and on scroll or resize —
* a menu anchored to coordinates goes stale the moment the page moves under it.
*/
export function openContextMenu(x: number, y: number, items: ContextMenuItem[]): HTMLElement {
closeContextMenu();
const el = document.createElement("div");
el.className = "context-menu";
el.setAttribute("role", "menu");
for (const item of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = item.danger ? "context-menu-item is-danger" : "context-menu-item";
btn.setAttribute("role", "menuitem");
btn.textContent = item.label;
btn.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
closeContextMenu();
item.onSelect();
});
el.appendChild(btn);
}
document.body.appendChild(el);
// Nudge back inside the viewport when the click was near an edge. happy-dom
// reports 0 for every measurement, which lands the menu at the click point —
// the same place it would sit with room to spare.
const rect = el.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
el.style.left = `${Math.max(0, Math.min(x, maxX))}px`;
el.style.top = `${Math.max(0, Math.min(y, maxY))}px`;
const onPointerDown = (ev: Event) => {
if (!el.contains(ev.target as Node)) closeContextMenu();
};
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === "Escape") closeContextMenu();
};
const dispose = () => {
if (open?.el !== el) return;
open = null;
el.remove();
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("scroll", closeContextMenu, true);
window.removeEventListener("resize", closeContextMenu);
};
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("keydown", onKeyDown, true);
window.addEventListener("scroll", closeContextMenu, true);
window.addEventListener("resize", closeContextMenu);
open = { el, dispose };
(el.firstElementChild as HTMLElement | null)?.focus();
return el;
}
-229
View File
@@ -1,229 +0,0 @@
// The link to the daemon behind this page: is it up, and which release is it?
// The sidebar's status indicator and the restart overlay both read this one
// controller. See docs/RESTART.md for the restart contract it implements.
import { getDaemonInfo } from "./api.js";
import type { Scheduler } from "./events.js";
import type { BuildInfo, DaemonInfo } from "./types.js";
/**
* - `connecting` — first probe hasn't landed yet.
* - `online` — the daemon answered and is the same process, or a resolved new one.
* - `reconnecting` — the socket dropped, but within the grace window; a blip
* shouldn't throw a modal in the user's face.
* - `down` — unreachable past the grace window: the overlay blocks the UI and
* the poll runs until it answers.
* - `updating` — it answered on a different commit; the page is reloading and
* stays blocked until it does.
*/
export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating";
export interface LinkStatus {
state: LinkState;
/** The release the daemon last reported. Undefined until the first probe,
* and on a daemon too old to carry one. */
build?: BuildInfo;
/** Hostname the daemon reports, for the indicator's tooltip. */
hostname?: string;
/** How many probes have failed in the current outage — the overlay counts
* attempts so a long restart doesn't look frozen. */
attempts: number;
}
/** What happened between two `/api/info` replies. */
export type RestartKind = "same" | "restarted" | "upgraded";
/** How long a dropped socket may stay dropped before the UI blocks. Long enough
* to ride out a wifi hiccup, short enough that a real restart is visible. */
export const GRACE_MS = 1200;
/** Cadence of the `/api/info` poll while the daemon is unreachable. */
export const POLL_MS = 700;
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
/**
* Compare two daemon replies.
*
* The commit decides first: a different one means new code, which for a browser
* means new assets. The per-process `instance` catches the rest — a restart of
* the same build, which needs no reload. A daemon built outside a git checkout
* reports an empty commit, so an empty-vs-empty pair never counts as an
* upgrade; the instance still reports the restart.
*/
export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
const a = before.build?.commit ?? "";
const b = after.build?.commit ?? "";
if (a && b && a !== b) return "upgraded";
if (before.instance && after.instance && before.instance !== after.instance) return "restarted";
return "same";
}
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when
* the build carried uncommitted changes. Empty builds say so rather than
* rendering a blank tooltip. */
export function formatRelease(build: BuildInfo | undefined): string {
if (!build?.commit) return "unknown release";
const day = build.commit_date ? build.commit_date.slice(0, 10) : "";
const parts = [build.commit];
if (day) parts.push(`· ${day}`);
if (build.dirty) parts.push("(dirty)");
return parts.join(" ");
}
export interface DaemonLinkOptions {
/** Injectable for tests; defaults to `GET /api/info`. */
fetchInfo?: () => Promise<DaemonInfo>;
scheduler?: Scheduler;
/** What an upgrade does. Injectable so tests don't navigate. */
reload?: () => void;
graceMs?: number;
pollMs?: number;
}
export type LinkListener = (status: LinkStatus) => void;
export class DaemonLink {
private state: LinkState = "connecting";
private identity: DaemonInfo | null = null;
private attempts = 0;
private graceTimer: number | null = null;
private pollTimer: number | null = null;
private readonly listeners = new Set<LinkListener>();
private readonly fetchInfo: () => Promise<DaemonInfo>;
private readonly scheduler: Scheduler;
private readonly reload: () => void;
private readonly graceMs: number;
private readonly pollMs: number;
constructor(opts: DaemonLinkOptions = {}) {
this.fetchInfo = opts.fetchInfo ?? getDaemonInfo;
this.scheduler = opts.scheduler ?? browserScheduler;
this.reload = opts.reload ?? (() => window.location.reload());
this.graceMs = opts.graceMs ?? GRACE_MS;
this.pollMs = opts.pollMs ?? POLL_MS;
}
/** Record the release we started against. Everything later is a diff on it. */
async start(): Promise<void> {
await this.probe();
}
status(): LinkStatus {
return {
state: this.state,
build: this.identity?.build,
hostname: this.identity?.hostname,
attempts: this.attempts,
};
}
subscribe(listener: LinkListener): () => void {
this.listeners.add(listener);
listener(this.status());
return () => this.listeners.delete(listener);
}
/** The events socket dropped. Starts the grace timer and the poll. */
noteDisconnected(): void {
if (this.state === "down" || this.state === "updating") return;
this.attempts = 0;
this.setState("reconnecting");
if (this.graceTimer === null) {
this.graceTimer = this.scheduler.setTimer(() => {
this.graceTimer = null;
if (this.state === "reconnecting") this.setState("down");
void this.poll();
}, this.graceMs);
}
}
/** The events socket came back. It carries no identity of its own, so the
* state only clears once a probe confirms *which* daemon answered — a
* reconnect to an upgraded daemon must reload, not resume. */
noteConnected(): void {
if (this.state === "online" || this.state === "updating") return;
void this.probe();
}
/**
* Recheck liveness after the browser resumes from the background. Mobile
* browsers may suspend both WebSocket events and timers while a PWA is
* hidden, leaving a stale `down` state after the daemon is already back.
*/
recover(): void {
if (this.state === "updating") return;
this.noteDisconnected();
void this.probe();
}
/**
* One `/api/info` round-trip. Resolves `true` when the daemon answered.
*
* A first answer becomes the baseline. A later one that differs on commit
* reloads the page; anything else (plain restart, or the same process) puts
* the UI back online.
*/
async probe(): Promise<boolean> {
let info: DaemonInfo;
try {
info = await this.fetchInfo();
} catch {
this.attempts += 1;
this.emit();
return false;
}
const before = this.identity;
this.identity = info;
if (before && compareIdentity(before, info) === "upgraded") {
// Stay blocked through the reload: an unlocked frame here would let the
// user drive new server code from the bundle it just replaced.
this.setState("updating");
this.reload();
return true;
}
this.attempts = 0;
this.clearTimers();
this.setState("online");
return true;
}
/** Stop polling. The page's own teardown; not used in normal operation. */
stop(): void {
this.clearTimers();
}
private async poll(): Promise<void> {
if (this.state === "online" || this.state === "updating") return;
const ok = await this.probe();
if (ok || this.pollTimer !== null) return;
this.pollTimer = this.scheduler.setTimer(() => {
this.pollTimer = null;
void this.poll();
}, this.pollMs);
}
private clearTimers(): void {
if (this.graceTimer !== null) {
this.scheduler.clearTimer(this.graceTimer);
this.graceTimer = null;
}
if (this.pollTimer !== null) {
this.scheduler.clearTimer(this.pollTimer);
this.pollTimer = null;
}
}
private setState(state: LinkState): void {
if (this.state === state) return;
this.state = state;
this.emit();
}
private emit(): void {
const s = this.status();
for (const fn of this.listeners) fn(s);
}
}
-41
View File
@@ -1,41 +0,0 @@
// Debug-HUD toggle (#20). Off by default; persisted in localStorage so it
// survives reloads, flipped by a keybind (Ctrl+Shift+D), and force-enabled by
// `?hud` in dev. A `maestro-hud-toggle` event lets the HUD re-render on change.
import { CommandSet, defineCommands } from "./commands.js";
const STORAGE_KEY = "maestro:hud";
export const HUD_TOGGLE_EVENT = "maestro-hud-toggle";
/** Whether the debug HUD should be shown. */
export function isHudEnabled(): boolean {
if (import.meta.env.DEV && location.search.includes("hud")) return true;
try {
return localStorage.getItem(STORAGE_KEY) === "1";
} catch {
// Private-mode / storage-disabled: HUD just stays off.
return false;
}
}
/** Persist the HUD on/off state and notify listeners. */
export function setHudEnabled(on: boolean): void {
try {
if (on) localStorage.setItem(STORAGE_KEY, "1");
else localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore storage failures — the event still drives the in-memory HUD
}
window.dispatchEvent(new CustomEvent(HUD_TOGGLE_EVENT));
}
export function toggleHud(): void {
setHudEnabled(!isHudEnabled());
}
const hudKeys = new CommandSet(defineCommands({ "debug.toggleHud": () => toggleHud() }));
/** Global keybind: Ctrl+Shift+D toggles the debug HUD. */
export function installHudKeybind(): void {
window.addEventListener("keydown", (e) => hudKeys.handle(e));
}
-145
View File
@@ -1,145 +0,0 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./api.js", () => ({ listDir: vi.fn() }));
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
import { listDir } from "./api.js";
const listDirMock = vi.mocked(listDir);
let input: HTMLInputElement;
let nextField: HTMLInputElement;
let docKeydown: ReturnType<typeof vi.fn>;
let ac: DirAutocompleteHandle | undefined;
/** The dropdown stays in the DOM (with `hidden` toggling) rather than being
* added/removed, so queries need `{ hidden: true }` to see it while closed. */
function popup(): HTMLElement {
return screen.getByRole("listbox", { hidden: true });
}
function items(): HTMLElement[] {
return screen.getAllByRole("option", { hidden: true });
}
async function type(value: string): Promise<void> {
input.focus();
fireEvent.input(input, { target: { value } });
await vi.advanceTimersByTimeAsync(100); // past the fetch debounce
}
/** Fire a cancelable, bubbling keydown on the input; report whether the
* default browser behaviour (focus move, form submit) was suppressed. */
function press(key: string): { defaultPrevented: boolean } {
const notPrevented = fireEvent.keyDown(input, { key });
return { defaultPrevented: !notPrevented };
}
beforeEach(() => {
vi.useFakeTimers();
document.body.replaceChildren();
input = document.createElement("input");
nextField = document.createElement("input");
document.body.append(input, nextField);
docKeydown = vi.fn();
document.addEventListener("keydown", docKeydown);
listDirMock.mockReset().mockResolvedValue({ path: "/tmp", entries: ["proj-a", "proj-b", "other"], truncated: false });
});
afterEach(() => {
ac?.destroy();
document.removeEventListener("keydown", docKeydown);
vi.useRealTimers();
});
describe("dirAutocomplete dismissal", () => {
it("Escape closes the popup, keeps the typed value and does not propagate", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
expect(popup()).toBeVisible();
const { defaultPrevented } = press("Escape");
expect(defaultPrevented).toBe(true);
expect(popup()).not.toBeVisible();
expect(input.value).toBe("/tmp/pro");
expect(input).toHaveFocus();
expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire
});
it("blur dismisses the popup", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
expect(popup()).toBeVisible();
input.blur();
await vi.advanceTimersByTimeAsync(150);
expect(popup()).not.toBeVisible();
});
it("does not reopen on refocus until the operator types again", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
input.blur();
await vi.advanceTimersByTimeAsync(150);
input.focus();
await vi.advanceTimersByTimeAsync(150);
expect(popup()).not.toBeVisible();
await type("/tmp/ot");
expect(popup()).toBeVisible();
});
});
describe("dirAutocomplete keyboard model", () => {
it("Tab commits the highlighted entry and lets focus move to the next field", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro"); // highlight defaults to the first match: proj-a
nextField.focus();
const { defaultPrevented } = press("Tab");
expect(input.value).toBe("/tmp/proj-a/");
expect(defaultPrevented).toBe(false); // native tab navigation proceeds
expect(nextField).toHaveFocus(); // focus was not yanked back
});
it("Tab with nothing highlighted just moves on", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/nope-matches-nothing");
expect(popup()).not.toBeVisible();
const { defaultPrevented } = press("Tab");
expect(defaultPrevented).toBe(false);
expect(input.value).toBe("/tmp/nope-matches-nothing");
});
it("arrows move the highlight and Enter commits it without submitting", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/");
press("ArrowDown");
expect(items()[1]).toHaveAttribute("aria-selected", "true");
const enter = press("Enter");
expect(enter.defaultPrevented).toBe(true); // no form submit
expect(input.value).toBe("/tmp/proj-b/");
});
});
describe("dirAutocomplete opening", () => {
it("still opens on focus when not previously dismissed", async () => {
ac = attachDirAutocomplete(input);
input.focus();
await vi.advanceTimersByTimeAsync(50);
expect(popup()).toBeVisible();
expect(listDirMock).toHaveBeenCalled();
});
});
-235
View File
@@ -1,235 +0,0 @@
// Shell-style directory autocomplete for a path <input> (NAP-329).
//
// Lists one directory at a time over the live `GET /api/dirs` endpoint, caches
// per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/`
// hits the network. Keyboard model mirrors `cd dir/sub<tab>`:
//
// Tab commit the highlighted entry and let focus move on; with nothing
// highlighted, plain tab navigation
// ↑/↓ move the highlight
// Enter accept the highlighted entry (and suppress form submit)
// Esc close the dropdown, refocus the input, and swallow the key so it
// can't also discard the surrounding spawn modal
//
// Once dismissed (Esc or blur) the popup stays closed until the operator types
// again — refocusing alone doesn't reopen it.
//
// Failures (offline daemon, permission denied) degrade to no suggestions.
import { splitPath } from "./paths.js";
import { listDir } from "./api.js";
export interface DirAutocompleteHandle {
destroy(): void;
}
/** Most entries we render at once — a long list is unhelpful and slow. */
const MAX_VISIBLE = 60;
/** Debounce before a network fetch when the user crosses into a new directory. */
const FETCH_DEBOUNCE_MS = 90;
/** Split a path value into the directory to list and the trailing fragment
* being typed within it. The directory keeps its trailing `/` (or is empty,
* meaning "the daemon's home dir"). */
export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteHandle {
const dropdown = document.createElement("ul");
dropdown.className = "dir-ac";
dropdown.setAttribute("role", "listbox");
dropdown.hidden = true;
document.body.appendChild(dropdown);
// `dir -> entries`. Successful listings only; errors aren't cached so a
// transient failure can recover on the next keystroke.
const cache = new Map<string, string[]>();
let filtered: string[] = [];
let highlight = -1;
let fetchSeq = 0;
let debounce: number | undefined;
let open = false;
// Set when the operator dismissed the popup (Esc, blur, outside click); a
// refocus must not reopen it — only new typing does.
let dismissed = false;
function close(): void {
if (!open) return;
open = false;
dropdown.hidden = true;
dropdown.replaceChildren();
highlight = -1;
window.removeEventListener("scroll", reposition, true);
window.removeEventListener("resize", reposition);
}
function dismiss(): void {
dismissed = true;
close();
}
function reposition(): void {
const r = input.getBoundingClientRect();
dropdown.style.left = `${r.left}px`;
dropdown.style.top = `${r.bottom + 4}px`;
dropdown.style.width = `${r.width}px`;
}
function render(): void {
dropdown.replaceChildren();
if (filtered.length === 0) {
close();
return;
}
const shown = filtered.slice(0, MAX_VISIBLE);
shown.forEach((name, i) => {
const li = document.createElement("li");
li.className = "dir-ac-item" + (i === highlight ? " is-active" : "");
li.setAttribute("role", "option");
li.setAttribute("aria-selected", String(i === highlight));
li.textContent = name + "/";
// mousedown (not click) so we accept before the input's blur fires.
li.addEventListener("mousedown", (e) => {
e.preventDefault();
accept(name);
});
dropdown.appendChild(li);
});
if (filtered.length > MAX_VISIBLE) {
const more = document.createElement("li");
more.className = "dir-ac-more";
more.textContent = `+${filtered.length - MAX_VISIBLE} more — keep typing to narrow`;
dropdown.appendChild(more);
}
if (!open) {
open = true;
dropdown.hidden = false;
window.addEventListener("scroll", reposition, true);
window.addEventListener("resize", reposition);
}
reposition();
}
function applyFilter(entries: string[], prefix: string): void {
// A fetch can outlive the dismissal that was meant to close this popup
// (Tab-commit re-arms a refresh, then blur dismisses 120ms later). Showing
// it now would anchor a dropdown under an unfocused input that nothing
// closes until the next interaction.
if (dismissed || document.activeElement !== input) {
close();
return;
}
const lower = prefix.toLowerCase();
filtered = entries.filter((e) => e.toLowerCase().startsWith(lower));
highlight = filtered.length > 0 ? 0 : -1;
render();
}
// Fetch (or read from cache) the listing for the directory currently implied
// by the input, then filter by the trailing fragment.
function refresh(): void {
const { dir, prefix } = splitPath(input.value);
const cached = cache.get(dir);
if (cached) {
applyFilter(cached, prefix);
return;
}
const seq = ++fetchSeq;
listDir(dir)
.then((listing) => {
cache.set(dir, listing.entries);
if (seq !== fetchSeq) return; // a newer fetch superseded this one
// The input may have changed while in flight; re-split before filtering.
const cur = splitPath(input.value);
if (cur.dir === dir) applyFilter(listing.entries, cur.prefix);
})
.catch(() => {
if (seq === fetchSeq) close();
});
}
function scheduleRefresh(): void {
window.clearTimeout(debounce);
debounce = window.setTimeout(refresh, FETCH_DEBOUNCE_MS);
}
// Accept `name` as the next path segment: complete it and append `/` so the
// child directory's listing loads on the next interaction (the drill-in
// gesture). Unlike `accept`, doesn't steal focus — Tab uses this to commit
// and move to the next field in one gesture.
function commit(name: string): void {
const { dir } = splitPath(input.value);
input.value = `${dir}${name}/`;
// Notify other listeners (spawnForm marks the field user-edited).
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function accept(name: string): void {
commit(name);
input.focus();
}
function onInput(): void {
dismissed = false;
scheduleRefresh();
}
function onFocus(): void {
if (dismissed) return;
refresh();
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === "Escape") {
if (open) {
e.preventDefault();
// Swallow the key: an Escape that bubbles would also close the
// spawn modal hosting this form.
e.stopPropagation();
dismiss();
input.focus();
}
return;
}
if (e.key === "Tab") {
if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation
commit(filtered[highlight]); // no preventDefault — focus moves to the next field
return;
}
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
highlight = Math.min(highlight + 1, filtered.length - 1);
render();
} else if (e.key === "ArrowUp") {
e.preventDefault();
highlight = Math.max(highlight - 1, 0);
render();
} else if (e.key === "Enter") {
if (highlight >= 0 && highlight < filtered.length) {
e.preventDefault(); // accept the entry instead of submitting the form
accept(filtered[highlight]);
}
}
}
function onBlur(): void {
// Delay so a dropdown mousedown can complete first.
window.setTimeout(dismiss, 120);
}
input.addEventListener("input", onInput);
input.addEventListener("focus", onFocus);
input.addEventListener("keydown", onKeydown);
input.addEventListener("blur", onBlur);
return {
destroy() {
window.clearTimeout(debounce);
close();
input.removeEventListener("input", onInput);
input.removeEventListener("focus", onFocus);
input.removeEventListener("keydown", onKeydown);
input.removeEventListener("blur", onBlur);
dropdown.remove();
},
};
}
-48
View File
@@ -1,48 +0,0 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { fireEvent } from "@testing-library/react";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
import { installDrawer, type Drawer } from "./drawer.js";
function mount(): { shell: HTMLElement; sidebar: HTMLElement; drawer: Drawer } {
const shell = document.createElement("div");
const sidebar = document.createElement("aside");
const backdrop = document.createElement("div");
const row = document.createElement("div");
row.className = "tree-leaf";
sidebar.appendChild(row);
shell.append(sidebar, backdrop);
document.body.replaceChildren(shell);
const drawer = installDrawer(shell, sidebar, backdrop);
drawer.open();
return { shell, sidebar, drawer };
}
describe("mobile drawer", () => {
beforeEach(() => document.body.replaceChildren());
it("closes when a session row is tapped", () => {
const { shell, sidebar } = mount();
fireEvent.click(sidebar.querySelector(".tree-leaf") as HTMLElement);
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when a spawn is requested", () => {
const { shell } = mount();
fireEvent(document, new CustomEvent(OPEN_SPAWN_EVENT));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when spawn similar is requested", () => {
const { shell } = mount();
fireEvent(document, new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("leaves the backdrop click closing it", () => {
const { shell } = mount();
fireEvent.click(shell.lastElementChild as HTMLElement);
expect(shell.classList.contains("drawer-open")).toBe(false);
});
});
-83
View File
@@ -1,83 +0,0 @@
// Mobile off-canvas sidebar (issue #35); on desktop the `drawer-open` class is
// inert (CSS gates the drawer styling).
//
// Anything that puts a new view in the content area must close the drawer, or
// it renders behind the sidebar and the tap reads as a dead button. "Spawn
// similar" sits inside a session row but stops propagation, so it never reaches
// the row's own close — both spawn paths arrive here via document events.
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
/** Toggling the drawer from outside the shell (the pane header's ☰ on mobile),
* without threading the controller through every view. */
export const TOGGLE_DRAWER_EVENT = "maestro-toggle-drawer";
export function requestToggleDrawer(): void {
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
}
export interface Drawer {
open(): void;
close(): void;
toggle(): void;
isOpen(): boolean;
}
export function installDrawer(
shellEl: HTMLElement,
sidebarEl: HTMLElement,
backdropEl: HTMLElement,
): Drawer {
const drawer: Drawer = {
open: () => shellEl.classList.add("drawer-open"),
close: () => shellEl.classList.remove("drawer-open"),
toggle: () => shellEl.classList.toggle("drawer-open"),
isOpen: () => shellEl.classList.contains("drawer-open"),
};
backdropEl.addEventListener("click", () => drawer.close());
sidebarEl.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) drawer.close();
});
document.addEventListener(TOGGLE_DRAWER_EVENT, () => drawer.toggle());
document.addEventListener(OPEN_SPAWN_EVENT, () => drawer.close());
document.addEventListener(SPAWN_SIMILAR_EVENT, () => drawer.close());
return drawer;
}
/** Horizontal swipes drive the drawer (right opens, left closes) rather than
* the browser's back/forward navigation. Only a clearly horizontal gesture past
* a threshold counts, so vertical terminal scrolling (which owns its own touch
* handler) is untouched. */
export function installDrawerSwipe(drawer: Drawer): void {
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
document.addEventListener(
"touchstart",
(e) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
},
{ passive: true },
);
document.addEventListener(
"touchend",
(e) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
if (dx > 0 && !drawer.isOpen()) drawer.open();
else if (dx < 0 && drawer.isOpen()) drawer.close();
},
{ passive: true },
);
}
-195
View File
@@ -1,195 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
CatalogStore,
INITIAL_BACKOFF_MS,
MAX_BACKOFF_MS,
type CatalogState,
type Scheduler,
} from "./events.js";
import type { CatalogEvent, Session } from "./types.js";
function session(id: string): Session {
return {
id,
cwd: "/home/n",
command: "bash",
env: {},
metadata: {},
status: "Running",
created_at: "2026-08-22T00:00:00Z",
} as Session;
}
/** Just enough of a WebSocket for the store to drive, plus a way to push frames. */
class FakeSocket {
onmessage: ((ev: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
close(): void {
this.closed = true;
}
open(): void {
this.onopen?.();
}
emit(event: CatalogEvent): void {
this.onmessage?.({ data: JSON.stringify(event) });
}
drop(): void {
this.onclose?.();
}
}
/** Scheduler that records delays instead of waiting them out. */
function fakeScheduler(): Scheduler & { delays: number[]; runNext(): void } {
const queued: Array<() => void> = [];
return {
delays: [] as number[],
setTimer(fn, ms) {
this.delays.push(ms);
queued.push(fn);
return queued.length;
},
clearTimer() {},
runNext() {
queued.shift()?.();
},
};
}
function storeWith(sockets: FakeSocket[], refetch?: () => Promise<Session[]>) {
let i = 0;
const scheduler = fakeScheduler();
const store = new CatalogStore({
socket: () => sockets[i++] as unknown as WebSocket,
scheduler,
refetch,
});
return { store, scheduler };
}
describe("CatalogStore reducer", () => {
it("replaces state on Snapshot and applies deltas", () => {
const ws = new FakeSocket();
const { store } = storeWith([ws]);
store.start();
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
expect([...store.snapshot().sessions.keys()]).toEqual(["a"]);
ws.emit({ type: "SessionAdded", session: session("b") } as CatalogEvent);
expect([...store.snapshot().sessions.keys()].sort()).toEqual(["a", "b"]);
ws.emit({ type: "SessionRemoved", id: "a" } as CatalogEvent);
expect([...store.snapshot().sessions.keys()]).toEqual(["b"]);
});
it("ignores a frame that is not JSON", () => {
const ws = new FakeSocket();
const { store } = storeWith([ws]);
store.start();
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
ws.onmessage?.({ data: "not json" });
expect([...store.snapshot().sessions.keys()]).toEqual(["a"]);
});
it("refetches over REST when the server reports dropped frames", async () => {
const ws = new FakeSocket();
const refetch = vi.fn(async () => [session("fresh")]);
const { store } = storeWith([ws], refetch);
store.start();
ws.emit({ type: "Snapshot", sessions: [session("stale")] } as CatalogEvent);
ws.emit({ type: "Lagged", skipped: 4 } as CatalogEvent);
await vi.waitFor(() => expect(refetch).toHaveBeenCalledOnce());
expect([...store.snapshot().sessions.keys()]).toEqual(["fresh"]);
});
it("hands each subscriber a copy it can diff against the next one", () => {
const ws = new FakeSocket();
const { store } = storeWith([ws]);
const seen: CatalogState[] = [];
store.subscribe((s) => seen.push(s));
store.start();
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
ws.emit({ type: "SessionAdded", session: session("b") } as CatalogEvent);
const [, afterSnapshot, afterAdd] = seen;
expect(afterSnapshot.sessions).not.toBe(afterAdd.sessions);
expect(afterSnapshot.sessions.size).toBe(1);
expect(afterAdd.sessions.size).toBe(2);
});
});
describe("CatalogStore reconnect", () => {
it("replaces a suspended socket immediately", () => {
const first = new FakeSocket();
const second = new FakeSocket();
const { store } = storeWith([first, second]);
store.start();
store.reconnect();
expect(first.closed).toBe(true);
second.open();
// A late close from the old socket is deliberately ignored.
first.drop();
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
});
it("doubles the delay per attempt and caps it", () => {
const sockets = Array.from({ length: 8 }, () => new FakeSocket());
const { store, scheduler } = storeWith(sockets);
store.start();
for (let i = 0; i < 7; i++) {
sockets[i].drop();
scheduler.runNext();
}
expect(scheduler.delays[0]).toBe(INITIAL_BACKOFF_MS);
expect(scheduler.delays[1]).toBe(INITIAL_BACKOFF_MS * 2);
expect(scheduler.delays.at(-1)).toBe(MAX_BACKOFF_MS);
});
it("resets the delay once a connection opens", () => {
const sockets = [new FakeSocket(), new FakeSocket(), new FakeSocket()];
const { store, scheduler } = storeWith(sockets);
store.start();
sockets[0].drop();
scheduler.runNext();
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS * 2);
sockets[1].open();
expect(store.backoff()).toBe(INITIAL_BACKOFF_MS);
});
});
describe("connection listeners", () => {
it("report the socket opening and dropping", () => {
const sock = new FakeSocket();
const store = new CatalogStore({
socket: () => sock as unknown as WebSocket,
scheduler: fakeScheduler(),
});
const seen: boolean[] = [];
store.subscribeConnection((up) => seen.push(up));
store.start();
// Nothing on subscribe — the daemon link's own probe sets the first state.
expect(seen).toEqual([]);
sock.open();
sock.drop();
expect(seen).toEqual([true, false]);
});
});
-204
View File
@@ -1,204 +0,0 @@
import { eventsWsUrl, listSessions } from "./api.js";
import type { CatalogEvent, Session, Uuid } from "./types.js";
export interface CatalogState {
sessions: Map<Uuid, Session>;
}
export type CatalogListener = (state: CatalogState) => void;
/** Opens the events socket. Injectable so the reducer and the backoff schedule
* can be driven without a server. */
export type SocketFactory = () => WebSocket;
/** Schedules a reconnect attempt; returns a handle `clearTimer` understands. */
export interface Scheduler {
setTimer(fn: () => void, ms: number): number;
clearTimer(handle: number): void;
}
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
/** Initial reconnect delay, doubling to `MAX_BACKOFF_MS`. */
export const INITIAL_BACKOFF_MS = 500;
export const MAX_BACKOFF_MS = 10_000;
export interface CatalogStoreOptions {
socket?: SocketFactory;
scheduler?: Scheduler;
/** Full-state refetch used when the server reports it dropped frames. */
refetch?: () => Promise<Session[]>;
}
/** Called with `true` when the events socket opens, `false` when it drops.
* Drives the daemon link (daemonLink.ts) — the socket dying is the first sign
* the daemon went down or was upgraded under the page. */
export type ConnectionListener = (up: boolean) => void;
export class CatalogStore {
private state: CatalogState = { sessions: new Map() };
private listeners = new Set<CatalogListener>();
private connectionListeners = new Set<ConnectionListener>();
private ws: WebSocket | null = null;
private reconnectTimer: number | null = null;
private backoffMs = INITIAL_BACKOFF_MS;
private readonly socket: SocketFactory;
private readonly scheduler: Scheduler;
private readonly refetch: () => Promise<Session[]>;
constructor(opts: CatalogStoreOptions = {}) {
this.socket = opts.socket ?? (() => new WebSocket(eventsWsUrl()));
this.scheduler = opts.scheduler ?? browserScheduler;
this.refetch = opts.refetch ?? listSessions;
}
start(): void {
this.connect();
}
/** Replace a socket that may have gone stale while the browser was suspended. */
reconnect(): void {
if (this.reconnectTimer !== null) {
this.scheduler.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
// A close event from the superseded socket must not schedule a second
// connection alongside the new one.
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close();
this.ws = null;
}
this.connect();
}
/** Current reconnect delay, for tests asserting the backoff schedule. */
backoff(): number {
return this.backoffMs;
}
stop(): void {
if (this.reconnectTimer !== null) {
this.scheduler.clearTimer(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.onclose = null;
this.ws.close();
this.ws = null;
}
}
/** Watch the socket's up/down transitions. Not called on subscribe: the
* caller's own probe decides the starting state. */
subscribeConnection(listener: ConnectionListener): () => void {
this.connectionListeners.add(listener);
return () => this.connectionListeners.delete(listener);
}
subscribe(listener: CatalogListener): () => void {
this.listeners.add(listener);
listener(this.snapshot());
return () => this.listeners.delete(listener);
}
/** A copy, so a listener can diff it against the previous one. Returning the
* live object meant in-place mutation was invisible and every event forced a
* full re-render. */
snapshot(): CatalogState {
return { sessions: new Map(this.state.sessions) };
}
private connect(): void {
const ws = this.socket();
this.ws = ws;
ws.onmessage = (ev) => {
let event: CatalogEvent;
try {
event = JSON.parse(ev.data) as CatalogEvent;
} catch {
return;
}
this.apply(event);
};
ws.onopen = () => {
this.backoffMs = INITIAL_BACKOFF_MS;
this.emitConnection(true);
};
ws.onclose = () => {
this.ws = null;
this.emitConnection(false);
this.scheduleReconnect();
};
ws.onerror = () => {
try {
ws.close();
} catch {
// ignore
}
};
}
private scheduleReconnect(): void {
if (this.reconnectTimer !== null) return;
const delay = Math.min(this.backoffMs, MAX_BACKOFF_MS);
this.reconnectTimer = this.scheduler.setTimer(() => {
this.reconnectTimer = null;
this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS);
this.connect();
}, delay);
}
private apply(event: CatalogEvent): void {
switch (event.type) {
case "Snapshot":
this.state = {
sessions: new Map(event.sessions.map((s) => [s.id, s])),
};
break;
case "SessionAdded":
this.state.sessions.set(event.session.id, event.session);
break;
case "SessionRemoved":
this.state.sessions.delete(event.id);
break;
case "SessionUpdated":
this.state.sessions.set(event.session.id, event.session);
break;
case "Lagged":
// Re-derive from REST to avoid showing stale state.
void this.resync();
return;
}
this.emit();
}
private async resync(): Promise<void> {
try {
const sessions = await this.refetch();
this.state = {
sessions: new Map(sessions.map((s) => [s.id, s])),
};
this.emit();
} catch {
// surface in UI on next reconnect cycle; nothing else useful to do
}
}
private emitConnection(up: boolean): void {
for (const fn of this.connectionListeners) fn(up);
}
private emit(): void {
const snap = this.snapshot();
for (const fn of this.listeners) fn(snap);
}
}
-127
View File
@@ -1,127 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import { defineCommands, keyCaps, publishCommands, publishedCommands } from "./commands.js";
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
beforeEach(() => {
installHelpKeybind();
});
afterEach(() => {
closeHelp();
document.body.replaceChildren();
});
function press(key: string, init: KeyboardEventInit = {}): void {
fireEvent.keyDown(window, { key, ...init });
}
function overlay(): HTMLElement {
return screen.getByRole("dialog", { name: "Keyboard shortcuts" });
}
function rows(root: HTMLElement): string[] {
return [...root.querySelectorAll(".help-row")].map((r) =>
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
);
}
describe("help overlay", () => {
it("renders exactly the commands the registry has bound, grouped", () => {
openHelp();
const dialog = overlay();
const groups = groupedCommands();
expect([...dialog.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
groups.map((g) => g.title),
);
expect(rows(dialog)).toEqual(groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))));
expect([...dialog.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
groups.flatMap((g) => g.items.map((c) => c.label)),
);
});
it("names each row by its command id, so a row can't drift from its command", () => {
openHelp();
const dialog = overlay();
expect(
[...dialog.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId),
).toEqual(publishedCommands().map((c) => c.id));
});
// #159: the leader comes from the daemon, so the overlay must render the
// configured chord — a hardcoded `Ctrl A` is the visible half of the bug.
it("renders the configured leader's chord, not a hardcoded Ctrl A", () => {
const bound = defineCommands(
{ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} },
"ctrl-b",
);
publishCommands(bound);
try {
openHelp();
const dialog = overlay();
const keysOf = (id: string) =>
[...dialog.querySelectorAll(`.help-row[data-command-id="${id}"] kbd`)]
.map((k) => k.textContent)
.join(" ");
expect(keysOf("pane.literalCtrlK")).toBe("Ctrl B then Ctrl K");
expect(keysOf("pane.literalLeader")).toBe("Ctrl B then Ctrl B");
} finally {
// Leave the registry as the rest of the suite expects it.
publishCommands(
defineCommands({ "pane.literalCtrlK": () => {}, "pane.literalLeader": () => {} }),
);
}
});
it("opens on ? and closes on a second ?", () => {
press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true);
press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(false);
});
it("closes on Escape, on the close button, and on a click outside the panel", () => {
openHelp();
press("Escape");
expect(isHelpOpen()).toBe(false);
openHelp();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(isHelpOpen()).toBe(false);
openHelp();
fireEvent.click(overlay());
expect(isHelpOpen()).toBe(false);
});
it("stays open for a click inside the panel", () => {
openHelp();
fireEvent.click(screen.getByRole("heading", { name: "Keyboard shortcuts", level: 2 }));
expect(isHelpOpen()).toBe(true);
});
it("opening twice leaves one overlay", () => {
openHelp();
openHelp();
expect(screen.getAllByRole("dialog")).toHaveLength(1);
});
it("ignores ? while typing and when a non-shift modifier is held", () => {
const input = document.createElement("input");
document.body.appendChild(input);
fireEvent.keyDown(input, { key: "?", shiftKey: true });
expect(isHelpOpen()).toBe(false);
press("?", { ctrlKey: true });
expect(isHelpOpen()).toBe(false);
});
it("installing the keybind twice still toggles once", () => {
installHelpKeybind();
press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true);
expect(screen.getAllByRole("dialog")).toHaveLength(1);
});
});
-134
View File
@@ -1,134 +0,0 @@
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
* overlay. It renders the command registry (`commands.ts`) — the same list
* that dispatches the keys — so a binding can never be documented as one
* thing and do another. */
import {
CommandSet,
defineCommands,
keyCaps,
publishedCommands,
type Command,
} from "./commands.js";
let open: { el: HTMLElement; dispose: () => void } | null = null;
export function isHelpOpen(): boolean {
return open !== null;
}
export function closeHelp(): void {
open?.dispose();
}
export function toggleHelp(): void {
if (open) closeHelp();
else openHelp();
}
/** Mount the overlay. Opening while already open is a no-op re-render, so a
* stray second call can never stack two dialogs. */
export function openHelp(): HTMLElement {
closeHelp();
const el = document.createElement("div");
el.className = "help-overlay";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("aria-label", "Keyboard shortcuts");
const panel = document.createElement("div");
panel.className = "help-panel";
const head = document.createElement("header");
head.className = "help-head";
const title = document.createElement("h2");
title.className = "help-title";
title.textContent = "Keyboard shortcuts";
const close = document.createElement("button");
close.type = "button";
close.className = "help-close";
close.setAttribute("aria-label", "Close");
close.textContent = "✕";
close.addEventListener("click", closeHelp);
head.append(title, close);
panel.appendChild(head);
for (const group of groupedCommands()) {
const section = document.createElement("section");
section.className = "help-group";
const heading = document.createElement("h3");
heading.className = "help-group-title";
heading.textContent = group.title;
const list = document.createElement("dl");
list.className = "help-list";
for (const cmd of group.items) {
const row = document.createElement("div");
row.className = "help-row";
row.dataset.commandId = cmd.id;
const keys = document.createElement("dt");
keys.className = "help-keys";
for (const key of keyCaps(cmd.keys[0])) {
const cap = document.createElement("kbd");
cap.textContent = key;
keys.appendChild(cap);
}
const desc = document.createElement("dd");
desc.className = "help-desc";
desc.textContent = cmd.label;
row.append(keys, desc);
list.appendChild(row);
}
section.append(heading, list);
panel.appendChild(section);
}
// A press on the scrim closes; one inside the panel is left alone.
el.addEventListener("click", (ev) => {
if (!panel.contains(ev.target as Node)) closeHelp();
});
el.appendChild(panel);
document.body.appendChild(el);
const dispose = () => {
if (open?.el !== el) return;
open = null;
el.remove();
};
open = { el, dispose };
close.focus();
return el;
}
/** The registry's commands, in registry order, bucketed into the help
* overlay's sections. Groups appear in the order their first command does. */
export function groupedCommands(): { title: string; items: Command[] }[] {
const groups: { title: string; items: Command[] }[] = [];
for (const cmd of publishedCommands()) {
let group = groups.find((g) => g.title === cmd.group);
if (!group) {
group = { title: cmd.group, items: [] };
groups.push(group);
}
group.items.push(cmd);
}
return groups;
}
// The overlay's own two commands. `help.close` is `overlay`-scoped: it is only
// fed keys while the dialog is up.
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => toggleHelp() }));
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => closeHelp() }));
let installed = false;
/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
* second call during a hot reload doesn't double-toggle. */
export function installHelpKeybind(): void {
if (installed) return;
installed = true;
window.addEventListener("keydown", (e) => {
if (open && overlayKeys.handle(e)) return;
helpKeys.handle(e);
});
}
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useHashRouter, type Route, type UseHashRouterResult } from "./useHashRouter.js";
let controls: UseHashRouterResult;
function Probe() {
controls = useHashRouter();
return <div>{controls.route.kind === "attach" ? `attach:${controls.route.sessionId}` : "catalog"}</div>;
}
beforeEach(() => {
location.hash = "";
});
afterEach(cleanup);
describe("useHashRouter", () => {
it("starts on catalog for an empty hash", () => {
const view = render(<Probe />);
expect(view.container.textContent).toBe("catalog");
});
it("resolves #/attach/:id to the attach route", () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
expect(view.container.textContent).toBe("attach:s1");
});
it("decodes an encoded session id", () => {
location.hash = `#/attach/${encodeURIComponent("weird id/x")}`;
const view = render(<Probe />);
expect(view.container.textContent).toBe("attach:weird id/x");
});
it("falls back to catalog for an unknown route", () => {
location.hash = "#/nonsense";
const view = render(<Probe />);
expect(view.container.textContent).toBe("catalog");
});
it("follows a hashchange fired outside the hook", async () => {
const view = render(<Probe />);
// happy-dom dispatches `hashchange` on a real timer, like a browser — not
// synchronously with the assignment.
location.hash = "#/attach/s9";
await waitFor(() => expect(view.container.textContent).toBe("attach:s9"));
});
it("navigate updates the hash and the route", async () => {
const view = render(<Probe />);
act(() => controls.navigate({ kind: "attach", sessionId: "s2" } satisfies Route));
expect(location.hash).toBe("#/attach/s2");
await waitFor(() => expect(view.container.textContent).toBe("attach:s2"));
});
it("navigate back to catalog sets the root hash", async () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
act(() => controls.navigate({ kind: "catalog" }));
expect(location.hash).toBe("#/");
await waitFor(() => expect(view.container.textContent).toBe("catalog"));
});
it("navigating to the already-current route still updates state", () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
act(() => controls.navigate({ kind: "attach", sessionId: "s1" } satisfies Route));
expect(view.container.textContent).toBe("attach:s1");
});
it("stops listening after unmount", async () => {
const view = render(<Probe />);
view.unmount();
location.hash = "#/attach/s3";
// No assertion beyond "doesn't throw" — a leaked listener would only ever
// surface as noise in a later test's act() warnings.
await new Promise((r) => setTimeout(r, 0));
});
});
+47
View File
@@ -0,0 +1,47 @@
// React port of main.ts's `parseRoute`/`dispatch` pair (issue #240). Same two
// routes, same hash shape (`#/`, `#/attach/:id`) — kept so a deep link or a
// bookmark from the vanilla shell still resolves.
import { useCallback, useEffect, useState } from "react";
export type Route = { kind: "catalog" } | { kind: "attach"; sessionId: string };
function parseHash(hash: string): Route {
const raw = hash.replace(/^#/, "");
if (raw === "" || raw === "/") return { kind: "catalog" };
const m = raw.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
return { kind: "catalog" };
}
function routeToHash(route: Route): string {
return route.kind === "catalog" ? "#/" : `#/attach/${encodeURIComponent(route.sessionId)}`;
}
export interface UseHashRouterResult {
route: Route;
navigate(route: Route): void;
}
export function useHashRouter(): UseHashRouterResult {
const [route, setRoute] = useState<Route>(() => parseHash(location.hash));
useEffect(() => {
const onHashChange = () => setRoute(parseHash(location.hash));
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
const navigate = useCallback((next: Route) => {
const hash = routeToHash(next);
// Setting an unchanged hash never fires `hashchange`, so the state update
// that would otherwise ride on that event has to happen here instead.
if (location.hash === hash) {
setRoute(next);
} else {
location.hash = hash;
}
}, []);
return { route, navigate };
}
-134
View File
@@ -1,134 +0,0 @@
// Game-style debug HUD (#20): a fixed-corner overlay on the active session
// showing the daemon-inferred fields (activity, foreground, idle time, status,
// …) live off the events WS — an FPS-counter for sessions, to validate the
// inferred data before any "real" UI is designed.
//
// It renders **whatever non-client-owned fields are present** on the Session, so
// a new daemon-derived field (#19 `detail`, later metrics) surfaces with no
// change here. Off by default — see `debug.ts`.
import { HUD_TOGGLE_EVENT, isHudEnabled } from "./debug.js";
import type { CatalogStore } from "./events.js";
import { sessionName, type Session, type Uuid } from "./types.js";
/** Fields the client itself owns/sets — not daemon-inferred, so the HUD skips
* them. Everything else on the Session is shown, including future fields. */
const CLIENT_OWNED = new Set([
"id",
"cwd",
"command",
"env",
"metadata",
"created_at",
]);
/** Friendlier labels for known keys; unknown (future) keys show their raw name. */
const LABELS: Record<string, string> = {
last_activity_at: "idle",
foreground: "fg",
};
export interface Hud {
/** Re-evaluate visibility + content (e.g. after navigation). */
refresh(): void;
dispose(): void;
}
/** Mount the HUD into `root`. It reflects the session returned by
* `getActiveId()`, updating on store changes, a 1s ticker (for idle time), and
* the toggle event. */
export function mountHud(
root: HTMLElement,
store: CatalogStore,
getActiveId: () => Uuid | null,
): Hud {
const el = document.createElement("aside");
el.className = "debug-hud";
el.hidden = true;
el.setAttribute("aria-hidden", "true");
root.appendChild(el);
const render = () => {
if (!isHudEnabled()) {
el.hidden = true;
return;
}
const id = getActiveId();
const session = id ? store.snapshot().sessions.get(id) : undefined;
if (!session) {
el.hidden = true;
return;
}
el.hidden = false;
el.replaceChildren(...buildRows(session));
};
const unsubscribe = store.subscribe(render);
const ticker = window.setInterval(render, 1000);
window.addEventListener(HUD_TOGGLE_EVENT, render);
render();
return {
refresh: render,
dispose() {
unsubscribe();
window.clearInterval(ticker);
window.removeEventListener(HUD_TOGGLE_EVENT, render);
el.remove();
},
};
}
function buildRows(session: Session): HTMLElement[] {
const rows: HTMLElement[] = [];
const head = document.createElement("div");
head.className = "hud-head";
head.textContent = sessionName(session);
rows.push(head);
for (const [key, value] of Object.entries(session)) {
if (CLIENT_OWNED.has(key) || value === undefined) continue;
rows.push(buildRow(key, value));
}
return rows;
}
function buildRow(key: string, value: unknown): HTMLElement {
const row = document.createElement("div");
row.className = "hud-row";
const k = document.createElement("span");
k.className = "hud-k";
k.textContent = LABELS[key] ?? key;
const v = document.createElement("span");
v.className = "hud-v";
if (key === "last_activity_at") {
v.textContent = relativeAge(value);
} else if (value === null) {
v.textContent = "—";
} else {
v.textContent = String(value);
}
// Lets the stylesheet color the activity value by state.
if (key === "activity" && typeof value === "string") {
v.dataset.activity = value;
}
row.append(k, v);
return row;
}
/** A timestamp rendered as "Ns" / "Nm Ns" / "Nh Nm" since now. */
function relativeAge(value: unknown): string {
if (typeof value !== "string") return "—";
const t = Date.parse(value);
if (!Number.isFinite(t)) return "—";
const secs = Math.max(0, Math.round((Date.now() - t) / 1000));
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ${secs % 60}s`;
const hrs = Math.floor(mins / 60);
return `${hrs}h ${mins % 60}m`;
}
+17 -2
View File
@@ -1,5 +1,6 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { App } from "./App.js";
import { ThemeProvider } from "./contexts/ThemeContext.js";
import "./main.css";
const container = document.getElementById("root");
@@ -7,4 +8,18 @@ if (!container) {
throw new Error("missing #root element");
}
createRoot(container).render(<App />);
const root = createRoot(container);
// Dev-only: `?kit` renders the standalone design kit instead of the app
// shell (#254). Dynamic import keeps it out of the production bundle.
if (import.meta.env.DEV && new URLSearchParams(location.search).has("kit")) {
void import("./components/kit/DesignKit.js").then(({ DesignKit }) => {
root.render(
<ThemeProvider>
<DesignKit />
</ThemeProvider>,
);
});
} else {
root.render(<App />);
}
-32
View File
@@ -1,32 +0,0 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { closeForceKillDialog, openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
describe("kill dialogs", () => {
beforeEach(() => {
closeForceKillDialog();
document.body.innerHTML = "";
});
it("focuses the kill button, so Enter confirms", () => {
openKillConfirmDialog({
name: "alpha",
blockers: null,
onKill: () => Promise.resolve(),
});
expect(screen.getByRole("button", { name: "Kill" })).toHaveFocus();
});
it("focuses the force button too", () => {
openForceKillDialog({
name: "alpha",
blockers: null,
reason: "worktree is dirty.",
onForce: () => Promise.resolve(),
});
expect(screen.getByRole("button", { name: "Force kill" })).toHaveFocus();
});
});
-165
View File
@@ -1,165 +0,0 @@
/**
* The two kill prompts (#81, #160).
*
* A kill is destructive and unattended agents live in these sessions, so both
* clients ask before one goes through — the TUI's `KillConfirm`, and
* `openKillConfirmDialog` here (#160). Where the session's worktree still
* holds uncommitted or unpushed work, the confirmation says so up front, the
* same way the TUI's does.
*
* The daemon decides the rest: it refuses a kill over such a worktree, and
* that refusal needs an answer the confirmation cannot give — a forced kill.
* `openForceKillDialog` is that second prompt. A kill the daemon accepts never
* reaches it.
*/
import { formatInTheWay, type InTheWay } from "./types.js";
/** What both prompts need: a target to name and a button that acts on it. */
interface KillDialogSpec {
/** Session name, as the sidebar shows it. */
name: string;
/** Sentence under the title. */
body: string;
/** Label on the acting button. */
confirmLabel: string;
/** Extra class on the acting button, so tests and styles can find it. */
confirmClass: string;
/** Run it. A rejection is rendered in the dialog, which stays open so the
* press can be retried. */
onConfirm: () => Promise<void>;
}
export interface KillConfirmPrompt {
name: string;
/** What the card last saw in the way, when anything was. Shown before the
* attempt, so "this will be refused" is not a surprise. */
blockers: InTheWay | null;
/** Run the plain kill. Opening a further dialog from here is allowed: the
* confirmation only closes itself if it is still the one on screen. */
onKill: () => Promise<void>;
}
export interface ForceKillPrompt {
/** Session name, as the sidebar shows it. */
name: string;
/** What the daemon reported in the way, when it reported anything. */
blockers: InTheWay | null;
/** Message from the daemon's refusal, shown when there is no breakdown. */
reason: string;
/** Run the forced kill. A rejection is rendered in the dialog, which stays
* open so the override can be retried. */
onForce: () => Promise<void>;
}
let open: { el: HTMLElement; onKeydown: (ev: KeyboardEvent) => void } | null = null;
export function closeForceKillDialog(): void {
if (!open) return;
window.removeEventListener("keydown", open.onKeydown, true);
open.el.remove();
open = null;
}
/** Mount one of the prompts. Opening while one is up replaces it, so the
* confirmation and a refusal that follows it can never stack. */
function mount(p: KillDialogSpec): HTMLElement {
closeForceKillDialog();
const el = document.createElement("div");
el.className = "kill-dialog";
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("aria-label", `Kill ${p.name}`);
const panel = document.createElement("div");
panel.className = "kill-dialog-panel";
const title = document.createElement("h2");
title.className = "kill-dialog-title";
title.textContent = `Kill "${p.name}"?`;
const body = document.createElement("p");
body.className = "kill-dialog-body";
body.textContent = p.body;
const error = document.createElement("p");
error.className = "kill-dialog-error";
error.hidden = true;
const actions = document.createElement("div");
actions.className = "kill-dialog-actions";
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn btn-ghost kill-dialog-cancel";
cancel.textContent = "Cancel";
cancel.addEventListener("click", closeForceKillDialog);
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = `btn btn-danger ${p.confirmClass}`;
confirm.textContent = p.confirmLabel;
confirm.addEventListener("click", () => {
confirm.disabled = true;
void p
.onConfirm()
.then(() => {
// A handler that opened the refusal prompt has already replaced this
// dialog; closing here would take that one down with it.
if (open?.el === el) closeForceKillDialog();
})
.catch((e: unknown) => {
if (open?.el !== el) return;
confirm.disabled = false;
error.hidden = false;
error.textContent = `Failed to kill: ${(e as Error).message}`;
});
});
actions.append(cancel, confirm);
panel.append(title, body, error, actions);
// A press on the scrim cancels; one inside the panel is left alone.
el.addEventListener("click", (ev) => {
if (!panel.contains(ev.target as Node)) closeForceKillDialog();
});
el.appendChild(panel);
document.body.appendChild(el);
const onKeydown = (ev: KeyboardEvent): void => {
if (ev.key !== "Escape") return;
ev.preventDefault();
ev.stopPropagation();
closeForceKillDialog();
};
window.addEventListener("keydown", onKeydown, true);
open = { el, onKeydown };
// The acting button takes the focus in both prompts: the operator already
// asked for the kill, so Enter confirms it. Escape and the scrim cancel.
confirm.focus();
return el;
}
/** Ask before a kill the daemon has not seen yet (#160). */
export function openKillConfirmDialog(p: KillConfirmPrompt): HTMLElement {
return mount({
name: p.name,
body: p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. The kill will be refused unless it is forced.`
: "The session and everything running in it go away. Its worktree stays on disk.",
confirmLabel: "Kill",
confirmClass: "kill-dialog-kill",
onConfirm: p.onKill,
});
}
/** Offer the override after the daemon refuses a guarded kill (#81). */
export function openForceKillDialog(p: ForceKillPrompt): HTMLElement {
return mount({
name: p.name,
body: p.blockers
? `Its worktree still has ${formatInTheWay(p.blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `${p.reason} Forcing the kill removes the session and leaves the worktree on disk.`,
confirmLabel: "Force kill",
confirmClass: "kill-dialog-force",
onConfirm: p.onForce,
});
}
-268
View File
@@ -1,268 +0,0 @@
/* =========================================================================
maestro web — design kit (DEV-only)
Loaded only by kit.ts (?kit). Layout for the specimen page + proposed
terminal-native button variants offered for critique. Nothing here ships:
kit.ts is dynamically imported behind import.meta.env.DEV.
========================================================================= */
.kit {
height: 100%;
overflow: auto;
background: var(--bg);
color: var(--ink);
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.kit-header {
position: sticky;
top: 0;
z-index: var(--z-sticky);
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-5);
border-bottom: 1px solid var(--border);
background: color-mix(in oklab, var(--surface) 82%, transparent);
backdrop-filter: blur(8px);
}
.kit-brand {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.kit-brand h1 {
margin: 0;
font-size: var(--fs-15);
font-weight: 600;
letter-spacing: -0.01em;
}
.kit-header-actions {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.kit-main {
max-width: 1080px;
margin: 0 auto;
padding: var(--sp-6) var(--sp-5) var(--sp-8);
display: flex;
flex-direction: column;
gap: var(--sp-8);
}
.kit-section-head {
margin-bottom: var(--sp-4);
}
.kit-section-title {
margin: 0;
font-size: var(--fs-13);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
}
.kit-section-note {
margin: 6px 0 0;
font-family: var(--font-sans);
font-size: var(--fs-13);
color: var(--muted);
max-width: 70ch;
}
.kit-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--sp-4);
}
/* A specimen cell: the component above, its name below. */
.kit-cell {
display: flex;
flex-direction: column;
gap: var(--sp-2);
align-items: flex-start;
}
.kit-slot {
display: flex;
align-items: center;
min-height: 44px;
padding: var(--sp-3);
border: 1px dashed var(--border);
border-radius: var(--r-2);
background: color-mix(in oklab, var(--surface) 40%, var(--bg));
}
.kit-slot .spawn-form {
padding: 0;
min-width: 200px;
}
.kit-label {
font-size: var(--fs-11);
color: var(--faint);
letter-spacing: 0.01em;
}
/* color swatches */
.kit-swatches {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--sp-3);
}
.kit-swatch {
display: flex;
flex-direction: column;
gap: 6px;
}
.kit-swatch-chip {
height: 48px;
border-radius: var(--r-2);
border: 1px solid var(--border);
}
/* session-row specimen sits in a sidebar-width container so wrapping/ellipsis
behave like the real sidebar. */
.kit-tree-host {
display: block;
}
.kit-tree {
width: clamp(280px, 26vw, 360px);
padding: var(--sp-2) 0;
background: color-mix(in oklab, var(--surface) 26%, var(--bg));
border: 1px solid var(--border);
border-radius: var(--r-2);
}
.kit-empties {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--sp-4);
align-items: start;
}
.kit-empties .empty {
border: 1px solid var(--border);
border-radius: var(--r-2);
background: color-mix(in oklab, var(--surface) 30%, var(--bg));
}
/* -------------------------------------------------------------------------
Proposed terminal-native button variants (critique only)
------------------------------------------------------------------------- */
.kit-btn-bracket,
.kit-btn-prompt,
.kit-btn-block,
.kit-btn-underline {
appearance: none;
cursor: pointer;
font-family: var(--font-mono);
font-size: var(--fs-13);
height: var(--control-h);
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: 1px solid transparent;
color: var(--ink);
transition:
color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
/* [ + Spawn ] — brackets are the chrome */
.kit-btn-bracket {
padding: 0 var(--sp-2);
}
.kit-btn-bracket::before {
content: "[";
color: var(--faint);
}
.kit-btn-bracket::after {
content: "]";
color: var(--faint);
}
.kit-btn-bracket:hover {
color: var(--accent);
}
.kit-btn-bracket:hover::before,
.kit-btn-bracket:hover::after {
color: var(--accent);
}
.kit-btn-bracket.is-primary {
color: var(--accent);
font-weight: 600;
}
.kit-btn-bracket.is-primary::before,
.kit-btn-bracket.is-primary::after {
color: var(--accent);
}
.kit-btn-bracket.is-primary:hover {
background: var(--accent-tint);
}
/* › spawn — a shell prompt */
.kit-btn-prompt {
padding: 0 var(--sp-2);
color: var(--muted);
}
.kit-btn-prompt::before {
content: "›";
color: var(--accent);
font-weight: 700;
}
.kit-btn-prompt:hover {
color: var(--ink);
}
/* SPAWN — boxed, uppercase, tight */
.kit-btn-block {
padding: 0 var(--sp-3);
border-color: var(--border-strong);
border-radius: var(--r-1);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: var(--fs-11);
font-weight: 600;
color: var(--muted);
}
.kit-btn-block:hover {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-tint);
}
/* spawn — underline only */
.kit-btn-underline {
padding: 0 2px;
border-radius: 0;
border-bottom: 1px solid var(--border-strong);
color: var(--muted);
}
.kit-btn-underline:hover {
color: var(--accent);
border-bottom-color: var(--accent);
}
.kit-btn-bracket:focus-visible,
.kit-btn-prompt:focus-visible,
.kit-btn-block:focus-visible,
.kit-btn-underline:focus-visible {
outline: 2px solid var(--accent-ring);
outline-offset: 2px;
border-radius: var(--r-1);
}
-316
View File
@@ -1,316 +0,0 @@
// Dev-only design kit. Lazily imported behind `import.meta.env.DEV` from
// main.ts (`?kit`), so it never ships in a production bundle. Renders every
// component specimen on one scrollable page so the visual system can be
// critiqued as a whole — buttons (including terminal-native variants for
// review), inputs, chips, badges, session rows, and empty states.
import "./kit.css";
import { createPaletteSelect, createThemeToggle } from "./theme.js";
function h(tag: string, className?: string, html?: string): HTMLElement {
const el = document.createElement(tag);
if (className) el.className = className;
if (html != null) el.innerHTML = html;
return el;
}
/** A titled section with an optional note and a content host. */
function section(title: string, note?: string): { el: HTMLElement; body: HTMLElement } {
const el = h("section", "kit-section");
const head = h("div", "kit-section-head");
head.appendChild(h("h2", "kit-section-title", title));
if (note) head.appendChild(h("p", "kit-section-note", note));
el.appendChild(head);
const body = h("div", "kit-row");
el.appendChild(body);
return { el, body };
}
/** A labelled specimen cell so each variant is named for critique. */
function specimen(label: string, node: Node): HTMLElement {
const cell = h("div", "kit-cell");
const slot = h("div", "kit-slot");
slot.appendChild(node);
cell.append(slot, h("span", "kit-label", label));
return cell;
}
function btn(className: string, label: string, disabled = false): HTMLButtonElement {
const b = document.createElement("button");
b.type = "button";
b.className = className;
b.textContent = label;
b.disabled = disabled;
return b;
}
/** A collapsible folder header, matching the real sidebar tree markup. */
function folderHeader(name: string, count: number, depth: number): HTMLElement {
const b = h("button", "tree-folder-header");
(b as HTMLButtonElement).type = "button";
b.style.paddingLeft = `${depth * 16}px`;
b.setAttribute("aria-expanded", "true");
b.innerHTML = `<span class="chevron">▾</span><span class="folder-name">${name}</span><span class="folder-count">${count}</span>`;
return b;
}
function leafRow(opts: {
name: string;
cmd: string;
status: "running" | "dead";
active?: boolean;
indent?: number;
activity?: "active" | "idle" | "awaiting-input" | "error";
duration?: string;
}): HTMLElement {
const row = h("a", "tree-leaf session-card");
(row as HTMLAnchorElement).href = "#/kit";
row.dataset.status = opts.status;
if (opts.active) row.classList.add("is-active");
const depth = opts.indent ?? 0;
row.style.paddingLeft = `${depth * 16 + 8}px`;
const flags = [
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
].join("");
const activity = opts.activity ?? "idle";
row.innerHTML = `
<span class="activity-rail" data-activity="${activity}" style="left:${depth * 16 + 2}px" title="${activity}"></span>
<span class="card-line1">
<span class="leaf-name">${opts.name}</span>
<span class="leaf-actions">
<button class="leaf-act leaf-clone" type="button" title="Spawn similar">⧉</button>
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
</span>
</span>
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
`;
return row;
}
export function renderKit(): HTMLElement {
const page = h("div", "kit");
// --- header ---
const header = h("header", "kit-header");
header.appendChild(
h(
"div",
"kit-brand",
`<span class="brand-dot" aria-hidden="true"></span>
<h1>maestro<span class="blink" aria-hidden="true">_</span> · design kit</h1>`,
),
);
const headerActions = h("div", "kit-header-actions");
const back = document.createElement("a");
back.className = "btn btn-ghost";
back.href = "?mock";
back.textContent = "← back to app";
headerActions.append(back, createThemeToggle(), createPaletteSelect());
header.appendChild(headerActions);
page.appendChild(header);
const main = h("div", "kit-main");
page.appendChild(main);
// --- color tokens ---
{
const { el, body } = section(
"Color tokens",
"Graphite neutrals; activity colours are the only hue. Swap by theme toggle + palette picker.",
);
body.classList.add("kit-swatches");
const tokens = [
"--bg",
"--surface",
"--surface-2",
"--surface-3",
"--border",
"--ink",
"--muted",
"--faint",
"--accent",
"--preview",
"--danger",
"--warn",
];
for (const t of tokens) {
const sw = h("div", "kit-swatch");
const chip = h("div", "kit-swatch-chip");
chip.style.background = `var(${t})`;
sw.append(chip, h("span", "kit-label", t));
body.appendChild(sw);
}
main.appendChild(el);
}
// --- buttons: current system ---
{
const { el, body } = section(
"Buttons — current",
"The shipping button styles — now the bracket terminal style, as used across the header, panes, and empty states.",
);
body.append(
specimen("btn-primary", btn("btn btn-primary", "+ Spawn")),
specimen("btn (default)", btn("btn", "Cancel")),
specimen("btn-ghost", btn("btn btn-ghost", "Dismiss")),
specimen("btn-icon", btn("btn btn-icon", "✕")),
specimen("btn:disabled", btn("btn btn-primary", "+ Spawn", true)),
);
main.appendChild(el);
}
// --- buttons: terminal-native variants for critique ---
{
const { el, body } = section(
"Buttons — other explorations",
"The bracket variant was adopted as the app style (above). These remain as alternates — not used in the app.",
);
body.append(
specimen("bracket", btn("kit-btn-bracket", "+ Spawn")),
specimen("bracket primary", btn("kit-btn-bracket is-primary", "+ Spawn")),
specimen("prompt caret", btn("kit-btn-prompt", "spawn")),
specimen("block", btn("kit-btn-block", "SPAWN")),
specimen("underline", btn("kit-btn-underline", "spawn")),
);
main.appendChild(el);
}
// --- inputs ---
{
const { el, body } = section("Inputs", "Mono inputs with a green focus ring.");
const search = h("input") as HTMLInputElement;
search.type = "search";
search.className = "search";
search.placeholder = "Search sessions…";
const text = h("div", "spawn-form");
const ti = document.createElement("input");
ti.type = "text";
ti.placeholder = "branch-name";
text.appendChild(ti);
const sel = h("div", "spawn-form");
const s = document.createElement("select");
s.innerHTML = `<option>thinkpad</option><option>tower</option><option>pi</option>`;
sel.appendChild(s);
body.append(
specimen("search", search),
specimen("text input", text),
specimen("select", sel),
);
main.appendChild(el);
}
// --- chips & badges ---
{
const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
const activeLine = h(
"span",
"card-line2",
`<span class="activity-duration">4m</span>`,
);
activeLine.setAttribute("data-activity", "active");
const rail = h("span", "activity-rail");
rail.dataset.activity = "active";
rail.style.cssText = "position:static;display:inline-block;height:14px;";
body.append(
specimen("folder-count", h("span", "folder-count", "4")),
specimen("activity-rail · active", rail),
specimen("card-line2 · active", activeLine),
specimen("attach-state · open", h("span", "attach-state", "live")),
);
(body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute(
"data-state",
"open",
);
const connecting = specimen("attach-state · connecting", h("span", "attach-state", "connecting"));
(connecting.querySelector(".attach-state") as HTMLElement).setAttribute("data-state", "connecting");
const closed = specimen("attach-state · closed", h("span", "attach-state", "closed"));
(closed.querySelector(".attach-state") as HTMLElement).setAttribute("data-state", "closed");
body.append(connecting, closed);
main.appendChild(el);
}
// --- session rows, nested by working directory ---
{
const { el, body } = section(
"Session rows — nested by host, then directory",
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); the activity rail sits in the indent gutter to the left; line 2 carries duration in the state's colour, then blocked-work / exited fragments, whenever there's something to say.",
);
const list = h("div", "kit-tree");
list.append(
folderHeader("~/git/maestro", 3, 0),
folderHeader("web", 2, 1),
leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }),
leafRow({
name: "dev-server",
cmd: "vite dev",
status: "running",
indent: 2,
active: true,
activity: "active",
duration: "4m",
}),
folderHeader("crates/daemon", 1, 1),
leafRow({
name: "daemon-work",
cmd: "claude",
status: "running",
indent: 2,
activity: "awaiting-input",
duration: "12s",
}),
leafRow({
name: "flaky-agent",
cmd: "claude",
status: "running",
indent: 2,
activity: "error",
duration: "2h",
}),
folderHeader("~/work/acme", 2, 0),
leafRow({
name: "billing-api",
cmd: "claude",
status: "running",
indent: 1,
activity: "idle",
duration: "26m",
}),
leafRow({
name: "scratch",
cmd: "bash",
status: "dead",
indent: 1,
activity: "idle",
duration: "5h",
}),
leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }),
);
body.classList.add("kit-tree-host");
body.appendChild(list);
main.appendChild(el);
}
// --- empty states ---
{
const { el, body } = section("Empty states", "Each one teaches the next action.");
body.classList.add("kit-empties");
const welcome = h("div", "empty");
welcome.innerHTML = `
<div class="empty-glyph" aria-hidden="true">⌘</div>
<div class="empty-title">No session open</div>
<p class="empty-hint">Pick a session from the list, or start a new one.</p>
<div class="empty-actions"></div>`;
welcome.querySelector(".empty-actions")!.appendChild(btn("btn btn-primary", "+ Spawn session"));
const noSessions = h("div", "empty");
noSessions.innerHTML = `
<div class="empty-glyph" aria-hidden="true">+</div>
<div class="empty-title">No sessions yet</div>
<p class="empty-hint">Start your first session — it'll show up here.</p>
<div class="empty-actions"></div>`;
noSessions.querySelector(".empty-actions")!.appendChild(btn("btn btn-primary", "+ Spawn session"));
body.append(welcome, noSessions);
main.appendChild(el);
}
return page;
}
+20
View File
@@ -1,2 +1,22 @@
/* The design tokens (`--bg`, `--ink`, the palettes) and the base/shell rules
live in styles.css; Tailwind's utilities are generated against them through
tailwind.config.ts, which maps every utility to a `var(--token)`. Both have
to load, and the tokens first. */
@import "./styles.css";
@import "tailwindcss";
@config "../tailwind.config.ts";
/* The terminal's special-key bar is for touch only, and only while the soft
keyboard is actually up: dismissing the keyboard doesn't blur the terminal,
so focus alone would leave the bar lingering. `html.kb-open` comes from
useViewport's visual-viewport shrink detection. Declared after the Tailwind
import so it wins over the utilities on the same element. */
.keybar-touch {
display: none;
}
@media (pointer: coarse) {
html.kb-open .keybar-touch {
display: flex;
}
}
-608
View File
@@ -1,608 +0,0 @@
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "@fontsource/jetbrains-mono/600.css";
import "@fontsource/jetbrains-mono/700.css";
import { renderSidebar } from "./catalog.js";
import { ConnectionPool } from "./connections.js";
import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js";
import { installHudKeybind } from "./debug.js";
import { installHelpKeybind, isHelpOpen } from "./help.js";
import { CommandSet, DEFAULT_LEADER, controlByte, defineCommands, isBarTypingTarget } from "./commands.js";
import { isCommandBarOpen, openCommandBar, SPAWN_DIRECTORY_EVENT } from "./commandBar.js";
import { DaemonLink } from "./daemonLink.js";
import { CatalogStore } from "./events.js";
import { installRestartOverlay } from "./restartOverlay.js";
import { shouldRetryClose } from "./reconnect.js";
import {
createSession,
fetchNames,
getDaemonInfo,
gitRoot,
projects as fetchProjects,
} from "./api.js";
import {
buildFastForwardRequest,
fastForwardSeed,
resolveFastForward,
} from "./fastForward.js";
import { FALLBACK_PRESETS } from "./spawnForm.js";
import { openSpawnModal } from "./spawnModal.js";
import { spawnSimilarProjectSeed, spawnSimilarSeed } from "./spawnSimilar.js";
import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js";
import {
FOCUS_PANE_EVENT,
OPEN_COMMAND_BAR_EVENT,
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
SPAWN_SIMILAR_EVENT,
} from "./catalog.js";
import { initTheme } from "./theme.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { installViewportSizing } from "./viewport.js";
import "./styles.css";
initTheme();
installViewportSizing();
installHudKeybind();
installHelpKeybind();
registerServiceWorker();
// Register the PWA service worker so the app is installable and the shell loads
// offline. Production only — in dev the SW would fight Vite's HMR.
function registerServiceWorker(): void {
if (import.meta.env.DEV) return;
if (!("serviceWorker" in navigator)) return;
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js").catch(() => {
// Best-effort: a failed registration must never break the app.
});
});
}
const rootEl = document.getElementById("app");
if (!rootEl) {
throw new Error("missing #app");
}
const root: HTMLElement = rootEl;
// Dev-only: `?kit` renders the standalone design kit instead of the app shell,
// so the component system can be reviewed as a whole. Stripped from production
// builds by Vite (dynamic import behind import.meta.env.DEV).
if (import.meta.env.DEV && location.search.includes("kit")) {
void import("./kit.js").then((m) => root.replaceChildren(m.renderKit()));
} else {
void bootApp();
}
async function bootApp(): Promise<void> {
const store = new CatalogStore();
// The daemon's home dir, for the command bar's `~/…` directory abbreviation.
// Absolute paths are a fine fallback until it lands.
let homeDir: string | undefined;
void getDaemonInfo()
.then((info) => {
homeDir = info.home_dir;
// The leader is the operator's, not this module's (#159): a daemon that
// names one rebinds the chords hanging off it, and the `?` overlay and the
// command bar re-render from the rebuilt set.
if (info.leader) useLeader(info.leader);
})
.catch(() => {
/* absolute paths and the default leader are fine fallbacks */
});
// The daemon's identity and liveness: names the release in the sidebar's status
// dot, and blocks the UI behind the restart overlay while the daemon is away
// (docs/RESTART.md). The events socket is the trigger — it dies first.
const link = new DaemonLink();
installRestartOverlay(link);
store.subscribeConnection((up) => (up ? link.noteConnected() : link.noteDisconnected()));
void link.start();
store.start();
// Installed mobile browsers suspend timers and WebSockets in the background.
// Replacing the socket on return avoids leaving the restart overlay up after a
// transient lost connection has already recovered.
function recoverFromBackground(): void {
if (document.visibilityState !== "visible") return;
link.recover();
store.reconnect();
}
document.addEventListener("visibilitychange", recoverFromBackground);
window.addEventListener("pageshow", recoverFromBackground);
window.addEventListener("online", recoverFromBackground);
// Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed
// without a running daemon. Stripped from production builds by Vite.
if (import.meta.env.DEV && location.search.includes("mock")) {
void import("./mock.js").then((m) => m.seedMock(store));
}
// ---- persistent shell: sidebar (list) + content (welcome | attach) ----
const shellEl = document.createElement("div");
shellEl.className = "app-shell";
shellEl.dataset.route = "catalog";
// Forward-declared so the sidebar's drag hook can reach the controller (created
// after the welcome renderer). The hook only fires on user interaction, by which
// point `mv` is assigned.
let mv: MultiView;
// Preview timing (docs/PREVIEW.md): the first move shows at once, and a burst
// — j held down, or the pointer sweeping the whole list — is coalesced so it
// costs a couple of pane mounts instead of one per row walked.
const schedulePreview = createPreviewSchedule((sessionId) => {
ensureMultiView();
mv?.previewSession(sessionId);
updateShellRoute();
});
const sidebar = renderSidebar(store, {
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
link,
onPreview: schedulePreview,
});
const content = document.createElement("section");
content.className = "content";
// Mobile (issue #35): the sidebar is an off-canvas drawer toggled by the ☰ in
// the pane header (and the welcome state). `installDrawer` owns the close rules (session tap, Spawn, Spawn
// similar, backdrop); on desktop the class it toggles is inert.
const backdrop = document.createElement("div");
backdrop.className = "mv-backdrop";
const drawer = installDrawer(shellEl, sidebar.el, backdrop);
installDrawerSwipe(drawer);
shellEl.append(sidebar.el, content, backdrop);
root.replaceChildren(shellEl);
// The pool owns attach connections independently of the route: navigating away
// parks a session (keeps it streaming); navigating back re-mounts it instantly.
// It can mount several panes at once — the layout tree decides how many. For now
// the tree only ever holds a single leaf (splits/tabs arrive in epic #37); this
// routes the existing single-session view through that model.
// `mv` is forward-declared above (the sidebar drag hook needs it); the pool's
// disconnect callback below also references it.
let hud: Hud | undefined;
const pool = new ConnectionPool({
onFocusChange: () => {
// Sidebar highlighting is driven by the multi-view controller; the pool only
// nudges the HUD here.
hud?.refresh();
},
// Both the explicit disconnect (⏻) and a session ending on its own close the
// pane — drop its leaf (collapsing the split) and tear down the socket. A dead
// pane is never left lying around.
onRequestClose: (id) => closePane(id),
onClosed: (id) => closePane(id),
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
store,
});
// Session death used to reach the UI on two uncoordinated channels — the row
// from the events socket, the pane from `ws.onclose` — so either could happen
// without the other. The catalog is the authority: a pane for a session that is
// no longer listed gets closed here.
store.subscribe((state) => {
for (const id of mv?.openSessions() ?? []) {
if (!state.sessions.has(id)) closePane(id);
}
});
function closePane(id: string): void {
pool.close(id);
mv?.closeSession(id);
updateShellRoute();
}
// Debug HUD (#20): a fixed-corner overlay on the active session's inferred
// fields, off by default (Ctrl+Shift+D, or `?hud` in dev).
hud = mountHud(root, store, () => pool.focused());
// ---- layout state ----
// Single source of truth for what's mounted: tabs of split trees. The hash route
// is just a deep-link into it (`#/attach/:id` focuses/opens a session). Restored
// from localStorage per device (issue #34); dead sessions self-prune on attach.
const layout: Layout = loadLayout() ?? emptyLayout();
// Debounced persist — the controller calls this after every layout change.
let saveTimer: number | undefined;
function scheduleSave(): void {
clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => saveLayout(layout), 400);
}
function infoFor(sessionId: string) {
const s = store.snapshot().sessions.get(sessionId);
return s ? { name: sessionName(s), command: s.command, view: s.view } : undefined;
}
/** Drive the shell's responsive layout off whether any pane is open. */
function updateShellRoute(): void {
// A preview is not an open pane (docs/PREVIEW.md), so the mobile shell does
// not swap the list out for a terminal on the way past a row.
const hasPanes = (mv?.openSessions() ?? openSessions(layout)).size > 0;
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
}
function renderWelcome(): HTMLElement {
const el = document.createElement("div");
el.className = "empty content-welcome";
el.innerHTML = `
<div class="empty-glyph" aria-hidden="true">⌘</div>
<div class="empty-title">No session open</div>
<p class="empty-hint">
Pick a session from the list, or start a new one. Open sessions keep
streaming in the background — switching back is instant.
</p>
<p class="empty-hint">Press <kbd>?</kbd> for keyboard shortcuts.</p>
<div class="empty-actions"></div>`;
const actions = el.querySelector(".empty-actions") as HTMLElement;
// Mobile-only: with no pane open there is no pane header to carry the ☰, so
// the drawer would be swipe-only. CSS hides this on desktop.
const sessions = document.createElement("button");
sessions.type = "button";
sessions.className = "btn btn-ghost welcome-sessions";
sessions.textContent = "☰ Sessions";
sessions.addEventListener("click", () => requestToggleDrawer());
actions.appendChild(sessions);
const spawn = document.createElement("button");
spawn.type = "button";
spawn.className = "btn btn-primary";
spawn.textContent = "+ Spawn session";
spawn.title = "Draft pane: n — fast-forward spawn: N";
spawn.addEventListener("click", () => requestOpenSpawn());
actions.appendChild(spawn);
return el;
}
function renderNotFound(raw: string): HTMLElement {
const el = document.createElement("div");
el.className = "not-found";
el.innerHTML = `
<h1>Unknown route</h1>
<p><code>#${raw.replace(/[<&]/g, "")}</code> doesn't match anything.</p>
<a class="btn btn-primary" href="#/">Back to catalog</a>`;
return el;
}
// The multi-view controller renders tabs + splits inside the content pane and
// mounts a live attach into each session leaf.
mv = createMultiView({
pool,
layout,
sidebar,
infoFor,
onDisconnect: closePane,
renderWelcome,
// A spawn fills a pane with its new session; the shell route may flip from
// catalog (no sessions) to attach.
onOpenSessionsChange: updateShellRoute,
onLayoutChange: scheduleSave,
});
let contentShowsMv = false;
/** Put the multi-view in the content pane, without rendering it. A preview
* renders once, on its own terms (`docs/PREVIEW.md`); a render here would
* first re-mount the pane it is about to replace, and that mount takes the
* keyboard away from the list being walked. */
function ensureMultiView(): void {
if (!contentShowsMv) {
content.replaceChildren(mv.el);
contentShowsMv = true;
}
}
function showMultiView(): void {
ensureMultiView();
mv.render();
updateShellRoute();
}
// Spawn affordances: the sidebar's footer button, its empty state, and
// `?mock&spawn` all open the spawn modal; "Spawn similar" seeds one from the
// cloned session (see spawnSimilar.ts).
function requestOpenSpawn(): void {
// The session lands in a new tab, not a split of the pane you were reading
// (#100). "Spawn similar" and the fast-forward key below deliberately keep
// the old behaviour: both act on the session in front of you, so a split
// beside it is the point.
// The session you were last on preselects its project: `n` then Enter spawns
// another session there without touching the picker.
const current = focusedSession();
openSpawnModal({
seed: current ? spawnSimilarProjectSeed(current) : undefined,
onSuccess: (session) => mountSpawned(session, { newTab: true }),
});
}
/** The session the operator was last on, if any. Shared by the plain spawn
* modal seed and fast-forward's project pick: both should land on "where I
* currently am". */
function focusedSession(): Session | undefined {
const focused = mv?.focusedSessionId() ?? null;
return focused ? store.snapshot().sessions.get(focused) : undefined;
}
/** The focused session's project — its base repo for a worktree session, its
* cwd otherwise (same rule as spawnSimilarProjectSeed). */
function focusedProjectCwd(): string | undefined {
const s = focusedSession();
if (!s) return undefined;
return s.worktree ? s.worktree.base_repo : s.cwd;
}
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
const s = (ev as CustomEvent<Session>).detail;
openSpawnModal({ seed: spawnSimilarSeed(s), onSuccess: (session) => mountSpawned(session) });
});
/** Show a freshly spawned session: bring the multi-view up (the shell may still
* be on the catalog route) and give the session a pane. */
function mountSpawned(session: Session, placement?: { newTab?: boolean }): void {
showMultiView();
mv.openSessionBeside(session.id, placement);
updateShellRoute();
}
// Fast-forward spawn (#67): the spawn modal's keystroke neighbour spawns
// immediately with every default resolved — most recent project, its last
// command (or the first non-custom preset), a fresh name, worktree on with
// a new branch named after that name. Anything unresolvable (no projects
// known yet, no command, no generated name) opens the form pre-filled with
// what did resolve instead.
async function spawnPresets(): Promise<SpawnPreset[]> {
try {
const info = await getDaemonInfo();
if (info.presets?.length) return info.presets;
} catch {
/* keep fallbacks */
}
return FALLBACK_PRESETS;
}
async function requestFastForward(): Promise<void> {
const [discovered, handle] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
const presets = await spawnPresets();
const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd());
const req = buildFastForwardRequest(resolved);
if (req) {
try {
const session = await createSession(req);
// Mount it, the way the TUI's fast path attaches. Without this the
// keystroke creates a session and leaves the UI untouched, so it reads
// as a dead key (#67 review).
mountSpawned(session);
return;
} catch {
// A failed POST degrades to the same pre-filled form as an
// unresolvable default — never a silent dead keystroke.
}
}
openSpawnModal({
seed: fastForwardSeed(resolved),
onSuccess: (session) => mountSpawned(session),
});
}
// Enter on a `~` row in the command bar (docs/COMMAND-BAR.md): the same
// fast-forward spawn, aimed at the chosen directory instead of the most recent
// project. A directory the project scan never saw stands in as its own project;
// one outside any repo spawns without a worktree, since there is no repo to cut
// a branch from. Anything still unresolved opens the pre-filled form.
async function requestSpawnInDirectory(path: string): Promise<void> {
const [discovered, handle] = await Promise.all([
fetchProjects().catch(() => [] as Project[]),
fetchNames(1)
.then((batch) => batch.names[0])
.catch(() => undefined),
]);
const presets = await spawnPresets();
const known = discovered.find((p) => p.path === path);
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
const resolved = resolveFastForward([project], presets, handle, path);
const req = buildFastForwardRequest(resolved);
if (req) {
const inRepo =
known !== undefined ||
(await gitRoot(path)
.then((r) => !!r.root)
.catch(() => false));
if (!inRepo) delete req.worktree;
try {
mountSpawned(await createSession(req));
return;
} catch {
// A failed POST degrades to the pre-filled form, never a dead row.
}
}
openSpawnModal({
seed: fastForwardSeed(resolved),
onSuccess: (session) => mountSpawned(session),
});
}
document.addEventListener(SPAWN_DIRECTORY_EVENT, (ev) => {
void requestSpawnInDirectory((ev as CustomEvent<string>).detail);
});
// The global half of the command registry (#148). Two sets, because they
// differ in one thing: the help overlay owns the keyboard while it is up, so
// the plain keys go deaf behind it while the focus chord does not — an
// attached terminal must never be able to trap the focus keys.
//
// `n` opens the spawn modal, `N` fast-forwards a spawn (#67). Both are plain
// keys, so `isTypingTarget` inside the set keeps them out of form fields and the terminal's
// hidden textarea.
const spawnKeys = new CommandSet(
defineCommands({
"spawn.open": () => requestOpenSpawn(),
"spawn.fastForward": () => void requestFastForward(),
}),
);
// Leader-h moves the keyboard between the session list and the pane, the web
// counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the
// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H
// is already claimed by the browser (history), so nothing unmodified — or
// modified with a common browser chord — could get the focus back out of it.
//
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// on `/api/info`): it holds the key as a partial chord and takes select-all off
// it, so the escape hatch docs/COMMAND-BAR.md describes has somewhere to land
// (#149).
let currentLeader = DEFAULT_LEADER;
function buildFocusKeys(leader: string): CommandSet {
return new CommandSet(
defineCommands(
{
"focus.toggle": () => {
if (sidebar.hasListFocus()) {
pool.focusTerminal();
} else {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
}
},
// The escape hatch: binding ctrl-k takes readline's
// kill-to-end-of-line away from every pane, and leader ctrl-k gives it
// back as the literal 0x0B byte (docs/COMMAND-BAR.md). It resolves in
// this set, ahead of the bar's, so the chord never opens the bar.
"pane.literalCtrlK": () => pool.sendToFocused("\x0b"),
// The leader itself, pressed twice, tmux-style: sends one literal
// leader byte and clears the pending chord instead of resolving as a
// dead chord (docs/COMMAND-BAR.md, #158). Built from the leader, not a
// hardcoded byte, so a rebound leader sends its own.
"pane.literalLeader": () => pool.sendToFocused(controlByte(leader)),
},
leader,
),
{ leader },
);
}
let focusKeys = buildFocusKeys(DEFAULT_LEADER);
/** Rebind the leader-owning set to the daemon's leader. Re-registering the
* same command ids replaces them, so the help overlay and the command bar —
* both of which render the published registry when they open — show the
* configured chord rather than the default. */
function useLeader(leader: string): void {
if (leader === currentLeader) return;
currentLeader = leader;
focusKeys = buildFocusKeys(leader);
}
// 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 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).
window.addEventListener(
"keydown",
(e) => {
// The bar owns the keyboard while it is up; its own input handles the keys.
if (isCommandBarOpen()) return;
if (focusKeys.handle(e)) return;
// The help overlay owns the keyboard while it is up.
if (isHelpOpen()) return;
// The pane's hidden textarea is not a typing target for the bar: ctrl-k is
// seen ahead of xterm and opens the bar (#152). A real form field — the
// spawn form's inputs — keeps its own Ctrl+K.
if (!isBarTypingTarget(e.target) && barKeys.handle(e)) return;
spawnKeys.handle(e);
},
true,
);
// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle,
// above) hands it back the other way.
document.addEventListener(FOCUS_PANE_EVENT, () => {
// Handing the keyboard to a previewed pane is choosing it: the preview stops
// being provisional rather than snapping back the moment the list blurs.
const previewed = mv.previewedSessionId();
if (previewed) focusSession(previewed);
pool.focusTerminal();
});
/** Open/focus a session and reflect it in the shell chrome. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely
* on `hashchange` alone — a split or drag can move focus without the hash
* changing, leaving a click back onto that session's row silently ignored. */
function focusSession(sessionId: string): void {
schedulePreview.cancel();
showMultiView();
mv.openSession(sessionId);
updateShellRoute();
}
document.addEventListener(SESSION_SELECT_EVENT, (e) => {
focusSession((e as CustomEvent<string>).detail);
});
type Route =
| { kind: "catalog" }
| { kind: "attach"; sessionId: string }
| { kind: "not-found"; raw: string };
function parseRoute(): Route {
const hash = location.hash.replace(/^#/, "");
if (hash === "" || hash === "/") return { kind: "catalog" };
const m = hash.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
return { kind: "not-found", raw: hash };
}
function dispatch(): void {
const route = parseRoute();
switch (route.kind) {
case "attach":
// Deep-link: open/focus the session, then show the layout.
focusSession(route.sessionId);
return;
case "not-found":
shellEl.dataset.route = "notfound";
pool.setVisible([]);
pool.setFocus(null);
content.replaceChildren(renderNotFound(route.raw));
contentShowsMv = false;
return;
case "catalog":
// Show the current layout (welcome if empty) — navigating to #/ no longer
// tears panes down.
showMultiView();
return;
}
}
window.addEventListener("hashchange", dispatch);
dispatch();
} // bootApp
-156
View File
@@ -1,156 +0,0 @@
// Dev-only review fixture. Imported lazily behind `import.meta.env.DEV` from
// main.ts (`?mock`), so it never ships in a production bundle. It pushes a
// synthetic Snapshot through the store's normal event path.
import type { CatalogStore } from "./events.js";
import type { ActivityState, Session } from "./types.js";
// Rotate inferred activity across the fixture so the debug HUD (#20) shows all
// three states. Keyed off the id's last hex digit for stable variety.
const ACTIVITIES: ActivityState[] = ["Active", "Idle", "AwaitingInput"];
function sess(
id: string,
command: string,
status: "Running" | "Dead",
cwd: string,
name?: string,
blocked_work?: Session["blocked_work"],
): Session {
const seed = parseInt(id.slice(-1), 16) || 0;
return {
id,
cwd,
command,
env: {},
metadata: name ? { name } : {},
status,
created_at: "2026-06-15T12:00:00Z",
// Inferred fields (#18) so the HUD has real data to render in review.
foreground: command.split(" ")[0],
activity: ACTIVITIES[seed % ACTIVITIES.length],
last_activity_at: new Date(Date.now() - seed * 11_000).toISOString(),
// The session tree nests by current_cwd; default it to the spawn dir.
current_cwd: cwd,
blocked_work,
};
}
/** Stub the spawn-form backend endpoints (#26/#59) so `?mock&spawn` renders the
* full flow — presets, projects, gitroot — with no daemon running. Dev-only;
* delegates every other request to the real `fetch`. */
function stubSpawnApi(): void {
const real = globalThis.fetch.bind(globalThis);
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const path = url.startsWith("http") ? new URL(url).pathname + new URL(url).search : url;
if (path.startsWith("/api/info")) {
return Promise.resolve(
json({
hostname: "thinkpad.lan",
version: "0.1.0",
home_dir: "/home/dev",
presets: [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "opencode", label: "opencode", command: "opencode" },
{ id: "zsh", label: "Shell", command: "zsh" },
{ id: "custom", label: "Custom…", command: "" },
],
}),
);
}
if (path.includes("/projects")) {
const now = Date.now();
const iso = (daysAgo: number) => new Date(now - daysAgo * 86_400_000).toISOString();
return Promise.resolve(
json([
{
path: "/home/dev/git/maestro",
name: "maestro",
is_worktree: false,
last_activity: iso(0),
last_command: "claude",
},
{
path: "/home/dev/worktrees/maestro-59",
name: "maestro-59",
is_worktree: true,
base_repo: "/home/dev/git/maestro",
last_activity: iso(1),
last_command: "opencode",
},
{
path: "/home/dev/git/dotfiles",
name: "dotfiles",
is_worktree: false,
last_activity: iso(30),
last_command: "zsh",
},
{
path: "/home/dev/work/acme/api",
name: "api",
is_worktree: false,
},
]),
);
}
if (path.includes("/gitroot")) {
const root = path.includes("maestro") || path.includes("dotfiles")
? { root: "/home/dev/git/maestro" }
: {};
return Promise.resolve(json(root));
}
return real(input, init);
};
}
export function seedMock(store: CatalogStore): void {
const sessions: Session[] = [
// ~/git/maestro/web/* — deep dir chain; "~/git/maestro" collapses to one node.
sess("a1b2c3d4-0000-0000-0000-000000000001", "claude", "Running", "/home/dev/git/maestro/web", "redesign", {
uncommitted_files: 4,
untracked_files: 2,
unpushed_commits: 1,
insertions: 312,
deletions: 47,
}),
sess("a1b2c3d4-0000-0000-0000-000000000002", "vite dev", "Running", "/home/dev/git/maestro/web", "dev-server"),
sess("a1b2c3d4-0000-0000-0000-000000000003", "bash", "Dead", "/home/dev/git/maestro/web", "scratch"),
sess("a1b2c3d4-0000-0000-0000-000000000004", "codex --resume", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes", {
uncommitted_files: 1,
}),
// ~/git/maestro/crates/* — sibling subtree under the same repo.
sess("a1b2c3d4-0000-0000-0000-000000000005", "claude", "Running", "/home/dev/git/maestro/crates/daemon", "daemon-work"),
sess("a1b2c3d4-0000-0000-0000-000000000006", "cargo watch -x test", "Running", "/home/dev/git/maestro/crates/daemon", "test-loop"),
sess("a1b2c3d4-0000-0000-0000-000000000007", "cargo run -- daemon", "Running", "/home/dev/git/maestro/crates/daemon", "daemon"),
// ~/work/acme/* — a separate top-level dir on the same host.
sess("a1b2c3d4-0000-0000-0000-000000000008", "gemini", "Running", "/home/dev/work/acme/api", "billing-api", {
untracked_files: 3,
}),
sess("a1b2c3d4-0000-0000-0000-000000000009", "pnpm dev", "Dead", "/home/dev/work/acme/web", "dashboard"),
// ~/git/dotfiles — single session, fully collapsed chain.
sess("a1b2c3d4-0000-0000-0000-000000000010", "opencode", "Running", "/home/dev/git/dotfiles", "dotfiles"),
sess("a1b2c3d4-0000-0000-0000-000000000011", "btop", "Running", "/home/dev/git/infra", "monitor"),
// outside any home dir → shown verbatim (no ~ collapse).
sess("a1b2c3d4-0000-0000-0000-000000000012", "ssh gateway", "Dead", "/etc"),
// home root — a leaf directly under ~ (name from command).
sess("a1b2c3d4-0000-0000-0000-000000000013", "htop", "Running", "/home/dev"),
];
// The store applies CatalogEvents internally; feed a Snapshot the same way
// the WS would. `apply` is private, so reach it through an index cast.
(store as unknown as { apply: (e: unknown) => void }).apply({
type: "Snapshot",
sessions,
});
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
// backend endpoints stubbed so presets/projects/gitroot all render.
if (location.search.includes("spawn")) {
stubSpawnApi();
document.dispatchEvent(new CustomEvent("catalog-open-spawn"));
}
}
-422
View File
@@ -1,422 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { addTab, emptyLayout, leaves, type Layout } from "./layout.js";
import { splitLeaf } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import type { ConnectionPool } from "./connections.js";
import type { SidebarHandle } from "./catalog.js";
function stubFetch(): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/info")
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
: url.includes("/api/projects")
? []
: url.includes("/api/names")
? { names: ["cello"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
function fakePool(): ConnectionPool {
return {
setVisible() {},
setFocus() {},
} as unknown as ConnectionPool;
}
function fakeSidebar(): SidebarHandle {
return {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
setPreview() {},
focusList() {},
hasListFocus() {
return false;
},
dispose() {},
};
}
function makeMv(layout: Layout): MultiView {
return createMultiView({
pool: fakePool(),
layout,
sidebar: fakeSidebar(),
infoFor: () => ({ name: "sess", command: "bash" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
});
}
/** Drain the microtask queue so any pending async work lands. */
async function flush(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
describe("multiview empty panes", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("renders a placeholder, not a spawn form", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = splitLeaf(tab, first, null, "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2);
expect(screen.getByText(/Empty pane/)).toBeTruthy();
expect(mv.el.querySelector(".spawn-form")).toBeNull();
});
});
describe("multiview pane-drag swap", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Drag `sourcePane`'s title onto `targetPane`'s center, as the real pane
* drag handler does — modelled after the pointer sequence in runDrag()/
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
// The real drag-start gate requires the pointerdown target to sit inside
// an `.attach-header` (attach.ts's session title bar), which the fake pool
// never mounts; stand one in for the gate check itself.
const marker = document.createElement("div");
marker.className = "attach-header";
sourcePane.appendChild(marker);
vi.spyOn(targetPane, "getBoundingClientRect").mockReturnValue({
left: 100,
top: 0,
width: 100,
height: 100,
right: 200,
bottom: 100,
x: 100,
y: 0,
toJSON() {},
} as DOMRect);
(document as unknown as { elementsFromPoint: (x: number, y: number) => Element[] }).elementsFromPoint = () => [
targetPane,
];
fireEvent.pointerDown(marker, { clientX: 0, clientY: 0, bubbles: true, cancelable: true });
fireEvent.pointerMove(window, { clientX: 150, clientY: 50, bubbles: true, cancelable: true });
fireEvent.pointerUp(window, { clientX: 150, clientY: 50, bubbles: true, cancelable: true });
await flush();
}
it("swaps the two panes' sessions when one is dropped on the other's centre", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const leaf1 = splitLeaf(tab, "", "s1", "row", true);
const leaf2 = splitLeaf(tab, leaf1, "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
expect(leaves(tab.root).find((l) => l.id === leaf1)?.sessionId).toBe("s2");
expect(leaves(tab.root).find((l) => l.id === leaf2)?.sessionId).toBe("s1");
expect(tab.focusedLeafId).toBe(leaf2);
});
});
describe("openSessionBeside newTab (#100)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("opens its own tab instead of splitting the focused session", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = first;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openSessionBeside("s2", { newTab: true });
await flush();
expect(layout.tabs).toHaveLength(2);
// The tab you were reading is untouched: still one pane, still its session.
const original = leaves(layout.tabs[0].root);
expect(original).toHaveLength(1);
expect(original[0].sessionId).toBe("s1");
// The new session is alone in the new, now-active tab.
const fresh = leaves(layout.tabs[1].root);
expect(fresh).toHaveLength(1);
expect(fresh[0].sessionId).toBe("s2");
expect(layout.activeTabId).toBe(layout.tabs[1].id);
});
it("still splits when newTab is not asked for", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openSessionBeside("s2");
await flush();
expect(layout.tabs).toHaveLength(1);
expect(leaves(tab.root)).toHaveLength(2);
});
});
describe("multiview closeSession tab cleanup (#137)", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("closes the tab when its last session goes away", () => {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.closeSession("s2");
expect(layout.tabs).toHaveLength(1);
expect(layout.tabs[0].id).toBe(first.id);
expect(layout.activeTabId).toBe(first.id);
});
it("closes a background tab without stealing focus from the active one", () => {
const layout = emptyLayout();
const background = layout.tabs[0];
background.focusedLeafId = splitLeaf(background, "", "s1", "row", true);
const active = addTab(layout);
active.focusedLeafId = splitLeaf(active, "", "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.closeSession("s1");
expect(layout.tabs).toHaveLength(1);
expect(layout.tabs[0].id).toBe(active.id);
expect(layout.activeTabId).toBe(active.id);
});
it("keeps a tab that still holds another pane", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true);
addTab(layout);
layout.activeTabId = tab.id;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.closeSession("s2");
expect(layout.tabs).toHaveLength(2);
expect(leaves(tab.root)).toHaveLength(1);
});
it("keeps a tab holding an empty pane", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const s1 = splitLeaf(tab, "", "s1", "row", true);
splitLeaf(tab, s1, null, "row", true);
addTab(layout);
layout.activeTabId = tab.id;
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
await flush();
mv.closeSession("s1");
expect(layout.tabs).toHaveLength(2);
expect(leaves(tab.root)).toHaveLength(1);
});
it("never closes the only tab", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.closeSession("s1");
expect(layout.tabs).toHaveLength(1);
expect(layout.tabs[0].root).toBeNull();
expect(layout.activeTabId).toBe(tab.id);
});
});
describe("multiview mobile single pane", () => {
/** Force the mobile media query on, whatever happy-dom's viewport says. */
function forceMobile(): void {
vi.spyOn(window, "matchMedia").mockImplementation(
(query: string) =>
({
matches: query.includes("859"),
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList,
);
}
beforeEach(() => {
stubFetch();
forceMobile();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("replaces the focused pane instead of splitting beside it", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openSessionBeside("s2");
const ls = leaves(tab.root);
expect(ls).toHaveLength(1);
expect(ls[0].sessionId).toBe("s2");
expect(layout.tabs).toHaveLength(1);
});
it("keeps a spawned session in the current pane rather than a new tab", async () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
mv.openSessionBeside("s2", { newTab: true });
await flush();
expect(layout.tabs).toHaveLength(1);
const ls = leaves(tab.root);
expect(ls).toHaveLength(1);
expect(ls[0].sessionId).toBe("s2");
});
it("renders one pane and no pane pills for a multi-leaf tab", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
const first = splitLeaf(tab, "", "s1", "row", true);
tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(1);
expect(mv.el.querySelector(".mv-panebar")).toBeNull();
expect(mv.el.querySelector(".mv-pill")).toBeNull();
});
it("collapses the tab bar even with several tabs", () => {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
expect(mv.el).toHaveClass("mv-mobile-solo");
});
});
describe("multiview solo tab bar", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("hides the tab bar while only one tab exists", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
expect(mv.el).toHaveClass("mv-solo-tab");
});
it("shows the tab bar again once a second tab appears", () => {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
const mv = makeMv(layout);
document.body.appendChild(mv.el);
mv.render();
expect(mv.el).not.toHaveClass("mv-solo-tab");
});
});
-842
View File
@@ -1,842 +0,0 @@
// The multi-view renderer/controller (issues #30 splits, #31 tabs, #33
// highlights). Owns the tab bar + canvas inside the content pane, builds the
// active tab's split tree into DOM, and mounts a live AttachConnection into each
// session leaf via the pool. Empty leaves (freshly split, not yet filled) show a
// placeholder. Drag-to-dock is issue #32; mobile projection is #35.
import type { SidebarHandle } from "./catalog.js";
import type { ConnectionPool } from "./connections.js";
import {
activeTab,
addTab,
closeTab,
equalizeRatios,
findLeaf,
freshId,
type Dir,
type Layout,
leaves,
locateSession,
type Node,
openSessions,
removeLeaf,
splitLeaf,
type Tab,
} from "./layout.js";
import type { AttachInfo } from "./attach.js";
export interface MultiViewOptions {
pool: ConnectionPool;
layout: Layout;
sidebar: SidebarHandle;
/** Header text for a session's pane / attach view. */
infoFor: (sessionId: string) => AttachInfo | undefined;
/** Disconnect a session (the ⏻ tool): tear down its socket and close the pane. */
onDisconnect: (sessionId: string) => void;
/** Welcome content for an empty tab. */
renderWelcome: () => HTMLElement;
/** Called when a spawn fills a pane with a new session, so the shell can
* re-evaluate its route (sidebar vs attach layout). */
onOpenSessionsChange?: () => void;
/** Called after any layout change (structure, focus, or divider resize) so the
* shell can persist it (issue #34). */
onLayoutChange?: () => void;
}
export interface MultiView {
el: HTMLElement;
/** Bring a session into the layout: focus it if already open, else fill the
* focused pane (or open the first pane in an empty tab). */
openSession: (sessionId: string) => void;
openSessionBeside: (sessionId: string, opts?: { newTab?: boolean }) => void;
/** Drop the pane showing `sessionId` (the session was disconnected/killed). */
closeSession: (sessionId: string) => void;
/** Every session currently shown in some pane, across all tabs. */
openSessions: () => Set<string>;
/** Start dragging a sidebar session into the canvas (issue #32). The
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
* drag shows drop zones to place it. */
beginSessionDrag: (sessionId: string, e: PointerEvent) => void;
/** Rebuild the view from the current layout. */
render: () => void;
focusedSessionId: () => string | null;
/** Show `sessionId` in the content pane without committing to it
* (docs/PREVIEW.md): the tab holding it is brought forward, or a throwaway
* tab carries it. `null` puts back whatever was on screen before. */
previewSession: (sessionId: string | null) => void;
/** The session being previewed, or null. */
previewedSessionId: () => string | null;
}
export function createMultiView(opts: MultiViewOptions): MultiView {
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
// A preview must never reach localStorage: it is undone the moment the
// highlight moves on, and the layout it would save is not the operator's.
const persist = () => {
if (preview) return;
opts.onLayoutChange?.();
};
const el = document.createElement("div");
el.className = "mv";
const tabBar = document.createElement("div");
tabBar.className = "mv-tabs";
const canvas = document.createElement("div");
canvas.className = "mv-canvas";
el.append(tabBar, canvas);
// Mobile (issue #35): below 860px the layout collapses to exactly one visible
// pane and no tab bar — splits/drag/resize are desktop-only, and opening or
// spawning replaces the pane rather than adding a second thing to navigate.
// The sidebar drawer is the only navigation. The tree is untouched, so
// widening restores whatever splits and tabs a desktop session built.
const mq = window.matchMedia("(max-width: 859px)");
let isMobile = mq.matches;
mq.addEventListener("change", (e) => {
isMobile = e.matches;
render();
});
// ---- preview (docs/PREVIEW.md) ----
// A preview is a temporary *view* change, never a layout change: it records
// exactly what it moved so ending it puts the layout back byte for byte, and
// it neither persists nor reports pane focus to the daemon while it is up.
interface Preview {
sessionId: string;
/** Tab that was active before the preview. */
prevActiveTabId: string;
/** Focused leaf to restore, when the preview stole focus in a real tab. */
prevFocus: { tabId: string; leafId: string | null } | null;
/** Throwaway tab built for a session that no pane was showing. */
tempTabId: string | null;
}
let preview: Preview | null = null;
/// Set while a preview-driven render runs: ending a preview re-mounts the
/// pane that was there, and that mount must not pull the keyboard out of the
/// sidebar the operator is still walking.
let quietRender = false;
/** Render without any mount taking the keyboard. */
function renderQuietly(): void {
quietRender = true;
try {
render();
} finally {
quietRender = false;
}
}
const isTempTab = (tabId: string) => preview?.tempTabId === tabId;
/** Undo the preview's moves. Mutates only — the caller renders. */
function restorePreview(): boolean {
const p = preview;
if (!p) return false;
preview = null;
if (p.tempTabId) {
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
if (i >= 0) layout.tabs.splice(i, 1);
} else if (p.prevFocus) {
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
if (t) t.focusedLeafId = p.prevFocus.leafId;
}
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
? p.prevActiveTabId
: layout.tabs[0].id;
return true;
}
/** Keep what the preview is showing: its throwaway tab becomes a real one,
* and the focus it moved stands. Returns true when `sessionId` was the
* session being previewed; any other preview is simply discarded. */
function settlePreview(sessionId: string): boolean {
const p = preview;
if (!p) return false;
if (p.sessionId !== sessionId) {
restorePreview();
return false;
}
preview = null;
if (p.tempTabId) {
const t = layout.tabs.find((x) => x.id === p.tempTabId);
if (t) t.title = `Tab ${layout.tabs.length}`;
}
return true;
}
function previewSession(sessionId: string | null): void {
if (preview?.sessionId === sessionId) return;
if (sessionId === null) {
if (restorePreview()) renderQuietly();
return;
}
restorePreview();
const prevActiveTabId = layout.activeTabId;
const loc = locateSession(layout, sessionId);
if (loc) {
preview = {
sessionId,
prevActiveTabId,
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
tempTabId: null,
};
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
} else {
// Not on screen anywhere: a throwaway tab carries it, so no existing
// pane is evicted and nothing the operator arranged moves.
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
layout.tabs.push(tab);
layout.activeTabId = tab.id;
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
}
renderQuietly();
}
/** Any user action that changes the layout for real ends the preview first,
* so it never bakes a throwaway tab into the arrangement. */
function dropPreview(): void {
restorePreview();
}
// ---- highlight helpers ----
/** Sessions the operator actually has open — the preview's throwaway tab
* does not count, or hovering the sidebar would flip the shell's route. */
function realOpenSessions(): Set<string> {
const out = openSessions(layout);
if (preview?.tempTabId) out.delete(preview.sessionId);
return out;
}
function focusedLeafSession(): string | null {
const tab = activeTab(layout);
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
return fl?.sessionId ?? null;
}
/** The session the operator actually picked — what `focusedLeafSession()`
* would return with the preview undone. A preview moves the active tab and
* its focus, but it is a view: the accent stays on the row the operator
* chose until they choose another (docs/PREVIEW.md). */
function realFocusedSession(): string | null {
const p = preview;
if (!p) return focusedLeafSession();
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
const leafId =
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
const fl = leafId ? findLeaf(tab.root, leafId) : null;
return fl?.sessionId ?? null;
}
/** Update focus styling + pool focus + sidebar highlights without rebuilding
* the DOM (cheap path for click-to-focus). */
function syncFocus(): void {
const tab = activeTab(layout);
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
// The previewed pane's titlebar wears the preview colour instead of the
// accent tint a focused pane gets (docs/PREVIEW.md).
pane.classList.toggle(
"is-preview",
preview !== null && pane.dataset.sessionId === preview.sessionId,
);
}
// A preview is not a focused pane: reporting it would clear the session's
// `AwaitingInput` glow just because the operator scrolled past it.
if (!preview) pool.setFocus(focusedLeafSession());
sidebar.setHighlights({
open: realOpenSessions(),
focused: realFocusedSession(),
});
// The row the preview came from wears the same colour, so the sidebar and
// the pane read as one thing rather than two unrelated highlights.
sidebar.setPreview(preview?.sessionId ?? null);
}
function focusLeaf(leafId: string): void {
const tab = activeTab(layout);
if (tab.focusedLeafId === leafId) return;
tab.focusedLeafId = leafId;
syncFocus();
persist();
}
// ---- pane + split DOM ----
function buildPane(leaf: Extract<Node, { kind: "leaf" }>): {
el: HTMLElement;
host: HTMLElement;
sessionId: string | null;
} {
const pane = document.createElement("div");
pane.className = "mv-pane";
pane.dataset.leafId = leaf.id;
if (leaf.sessionId) pane.dataset.sessionId = leaf.sessionId;
const host = document.createElement("div");
host.className = "mv-pane-host";
if (!leaf.sessionId) {
const empty = document.createElement("div");
empty.className = "mv-pane-empty";
empty.textContent = "Empty pane — pick a session from the sidebar, or press n to spawn.";
host.appendChild(empty);
}
const tools = document.createElement("div");
tools.className = "mv-pane-tools";
const mkBtn = (label: string, title: string, fn: () => void) => {
const b = document.createElement("button");
b.type = "button";
b.className = "mv-tool";
b.title = title;
b.textContent = label;
b.addEventListener("pointerdown", (e) => e.stopPropagation());
b.addEventListener("click", (e) => {
e.stopPropagation();
fn();
});
return b;
};
// Order: disconnect (⏻) leftmost, then split-right / split-down / close.
// Splits are desktop-only; mobile keeps just disconnect + close.
if (leaf.sessionId) {
const off = mkBtn("⏻", "Disconnect", () => onDisconnect(leaf.sessionId!));
off.classList.add("mv-tool-off");
tools.appendChild(off);
}
if (!isMobile) {
tools.append(
mkBtn("▥", "Split right", () => splitFocused(leaf.id, "row")),
mkBtn("▤", "Split down", () => splitFocused(leaf.id, "col")),
);
}
tools.appendChild(mkBtn("✕", "Close pane", () => closePane(leaf.id)));
pane.append(host, tools);
pane.addEventListener(
"pointerdown",
(e) => {
// Clicking into a previewed pane is the operator choosing it.
if (preview && leaf.sessionId === preview.sessionId) {
settlePreview(leaf.sessionId);
persist();
}
focusLeaf(leaf.id);
// Drag a pane by its title bar (desktop only — no docking on mobile).
if (isMobile) return;
const t = e.target as HTMLElement;
if (t.closest(".attach-header") && !t.closest("button, a, .mv-tool")) {
runDrag({ kind: "pane", leafId: leaf.id }, e, { deferred: true });
}
},
true,
);
return { el: pane, host, sessionId: leaf.sessionId };
}
function wireDivider(
divider: HTMLElement,
split: Extract<Node, { kind: "split" }>,
slotA: HTMLElement,
slotB: HTMLElement,
container: HTMLElement,
): void {
divider.addEventListener("pointerdown", (e) => {
e.preventDefault();
e.stopPropagation();
divider.classList.add("is-dragging");
divider.setPointerCapture(e.pointerId);
const rect = container.getBoundingClientRect();
const move = (ev: PointerEvent) => {
const frac =
split.dir === "row"
? (ev.clientX - rect.left) / rect.width
: (ev.clientY - rect.top) / rect.height;
split.ratio = Math.max(0.1, Math.min(0.9, frac));
slotA.style.flexGrow = String(split.ratio);
slotB.style.flexGrow = String(1 - split.ratio);
};
const up = (ev: PointerEvent) => {
divider.classList.remove("is-dragging");
try {
divider.releasePointerCapture(ev.pointerId);
} catch {
/* already released */
}
divider.removeEventListener("pointermove", move);
divider.removeEventListener("pointerup", up);
persist(); // save the new split ratio
};
divider.addEventListener("pointermove", move);
divider.addEventListener("pointerup", up);
});
}
/** Build the tree DOM, collecting each session leaf's mount host. */
function buildTree(node: Node, hosts: { id: string; host: HTMLElement; info?: AttachInfo }[]): HTMLElement {
if (node.kind === "leaf") {
const pane = buildPane(node);
if (node.sessionId) {
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
}
return pane.el;
}
const container = document.createElement("div");
container.className = `mv-split ${node.dir}`;
const slotA = document.createElement("div");
slotA.className = "mv-slot";
slotA.style.cssText = `flex:${node.ratio} 1 0`;
const divider = document.createElement("div");
divider.className = "mv-divider";
const slotB = document.createElement("div");
slotB.className = "mv-slot";
slotB.style.cssText = `flex:${1 - node.ratio} 1 0`;
slotA.appendChild(buildTree(node.a, hosts));
slotB.appendChild(buildTree(node.b, hosts));
container.append(slotA, divider, slotB);
wireDivider(divider, node, slotA, slotB, container);
return container;
}
// ---- tab bar ----
function renderTabs(): void {
tabBar.replaceChildren();
for (const t of layout.tabs) {
const tab = document.createElement("button");
tab.type = "button";
tab.className =
"mv-tab" +
(t.id === layout.activeTabId ? " is-active" : "") +
(isTempTab(t.id) ? " is-preview" : "");
const n = leaves(t.root).filter((l) => l.sessionId).length;
const label = document.createElement("span");
label.textContent = t.title + (n ? ` · ${n}` : "");
tab.appendChild(label);
tab.addEventListener("click", () => {
if (isTempTab(t.id)) return; // a preview tab is not a place to go
dropPreview();
if (layout.activeTabId !== t.id) {
layout.activeTabId = t.id;
}
render();
});
// A preview tab is not the operator's to close: it goes on its own.
if (layout.tabs.length > 1 && !isTempTab(t.id)) {
const close = document.createElement("span");
close.className = "mv-tab-close";
close.textContent = "✕";
close.title = "Close tab";
close.addEventListener("click", (e) => {
e.stopPropagation();
dropPreview();
closeTab(layout, t.id);
render();
});
tab.appendChild(close);
}
tabBar.appendChild(tab);
}
const add = document.createElement("button");
add.type = "button";
add.className = "mv-tab-add";
add.textContent = "+";
add.title = "New tab";
add.addEventListener("click", () => {
dropPreview();
addTab(layout);
render();
});
tabBar.appendChild(add);
}
// ---- mutations ----
function splitFocused(leafId: string, dir: Dir): void {
dropPreview();
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
equalizeRatios(tab.root);
render();
}
function closePane(leafId: string): void {
dropPreview();
const tab = activeTab(layout);
removeLeaf(tab, leafId);
if (tab.focusedLeafId === leafId) {
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
}
render();
}
function openSession(sessionId: string): void {
// Opening what is already previewed keeps exactly what is on screen: the
// throwaway tab becomes a real one instead of the session moving again.
if (settlePreview(sessionId)) {
render();
return;
}
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
render();
return;
}
const tab = activeTab(layout);
if (!tab.root) {
const id = splitLeaf(tab, "", sessionId, "row", true); // root from empty
tab.focusedLeafId = id;
render();
return;
}
// Fill the focused pane (VS Code style: open replaces the active group).
const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0];
target.sessionId = sessionId;
tab.focusedLeafId = target.id;
render();
}
/// Mount a session without evicting one.
///
/// `openSession` deliberately replaces the focused pane (VS Code style) for
/// a sidebar click, but the fast path must never do that: pressing the key
/// while attached would drop the running session out of the layout
/// entirely. Reuses an empty pane, else splits beside the focused one
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
/// operator was reading (#100) — what the Spawn button asks for.
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
if (settlePreview(sessionId)) {
render();
opts.onOpenSessionsChange?.();
return;
}
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
render();
return;
}
// Mobile keeps a single tab and a single pane, so a new tab is not an
// option there: the session takes over the pane below.
if (placement?.newTab && !isMobile) {
const fresh = addTab(layout);
fresh.focusedLeafId = splitLeaf(fresh, "", sessionId, "row", true);
render();
opts.onOpenSessionsChange?.();
return;
}
const tab = activeTab(layout);
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
render();
opts.onOpenSessionsChange?.();
return;
}
const target = findLeaf(tab.root, tab.focusedLeafId)!;
// Mobile shows one pane, so there is no "beside": take over the pane. The
// evicted session keeps running and stays a tap away in the sidebar.
if (!target.sessionId || isMobile) {
target.sessionId = sessionId;
} else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
equalizeRatios(tab.root);
}
render();
opts.onOpenSessionsChange?.();
}
// ---- top-level render ----
function render(): void {
renderTabs();
canvas.replaceChildren();
const tab = activeTab(layout);
// Mobile: the tab bar is a second navigation surface we don't want, so CSS
// collapses it; the pane header's ☰ keeps the sidebar drawer reachable.
el.classList.toggle("mv-mobile-solo", isMobile);
// A lone tab has nothing to switch between, so the bar is only a strip of
// chrome; CSS hides it until a second tab exists. Spawning still opens its
// own tab (#100), which brings the bar back with the "+" button on it.
// The preview's throwaway tab must not make the bar appear and vanish as
// the highlight moves, so it does not count towards "one tab only".
el.classList.toggle(
"mv-solo-tab",
layout.tabs.filter((t) => !isTempTab(t.id)).length === 1,
);
el.classList.toggle("mv-previewing", preview !== null);
if (!tab.root) {
pool.setVisible([]);
if (!preview) pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
sidebar.setPreview(preview?.sessionId ?? null);
persist();
return;
}
// Keep focus pointing at a real leaf.
if (!tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
}
if (isMobile) {
renderMobile(tab);
persist();
return;
}
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
canvas.appendChild(buildTree(tab.root, hosts));
// A preview keeps the keyboard in the sidebar: mounting must not focus the
// terminal, or j/k would stop walking the list after the first step.
pool.setVisible(
hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })),
{ focus: !preview && !quietRender },
);
syncFocus();
persist();
}
// Mobile: the focused leaf, full screen, alone. Panes a desktop session left
// behind stay in the tree but are reached only by tapping the session in the
// sidebar drawer — mobile shows no pane switcher of its own.
function renderMobile(tab: { root: Node | null; focusedLeafId: string | null }): void {
const ls = leaves(tab.root);
const focused = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || ls[0];
const wrap = document.createElement("div");
wrap.className = "mv-mobile";
const pane = buildPane(focused);
pane.el.classList.add("is-focused"); // the only pane — never dimmed
if (preview && focused.sessionId === preview.sessionId) pane.el.classList.add("is-preview");
wrap.appendChild(pane.el);
canvas.appendChild(wrap);
// Only the focused leaf's session is live on mobile; the rest park.
pool.setVisible(
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
{ focus: !preview && !quietRender },
);
if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() });
sidebar.setPreview(preview?.sessionId ?? null);
}
function closeSession(sessionId: string): void {
dropPreview();
const loc = locateSession(layout, sessionId);
if (!loc) return;
removeLeaf(loc.tab, loc.leaf.id);
if (loc.tab.focusedLeafId === loc.leaf.id) {
loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null;
}
// A tab that held nothing but the dead session goes with it (#137): a killed
// session should not leave an empty tab behind for the operator to close by
// hand. `closeTab` keeps the last tab (emptied) so there is always one.
if (!loc.tab.root) closeTab(layout, loc.tab.id);
render();
}
// ---- drag-to-dock (issue #32) ----
// Drag a pane's title bar, or a session from the sidebar, onto another pane:
// edge zones split that pane, centre swaps (pane source) or replaces (session
// source). Sidebar drags move the session if it's already open elsewhere.
type Zone = "left" | "right" | "top" | "bottom" | "center";
type DragSource = { kind: "pane"; leafId: string } | { kind: "session"; sessionId: string };
let drag: {
source: DragSource;
ghost: HTMLElement;
overlay: HTMLElement | null;
targetLeafId: string | null;
zone: Zone | null;
} | null = null;
function dragSessionId(src: DragSource): string | null {
if (src.kind === "session") return src.sessionId;
return findLeaf(activeTab(layout).root, src.leafId)?.sessionId ?? null;
}
/** The leaf in the ACTIVE tab the source occupies (skip it as a drop target). */
function dragSelfLeaf(src: DragSource): string | null {
if (src.kind === "pane") return src.leafId;
const loc = locateSession(layout, src.sessionId);
return loc && loc.tab.id === layout.activeTabId ? loc.leaf.id : null;
}
function dragLabel(src: DragSource): string {
const sid = dragSessionId(src);
if (!sid) return "empty pane";
return infoFor(sid)?.name ?? sid;
}
function makeGhost(src: DragSource): HTMLElement {
const g = document.createElement("div");
g.className = "mv-drag-ghost";
g.textContent = `⠿ ${dragLabel(src)}`;
document.body.appendChild(g);
return g;
}
function runDrag(src: DragSource, e: PointerEvent, opts: { deferred: boolean }): void {
const sx = e.clientX;
const sy = e.clientY;
let started = !opts.deferred;
if (started) drag = { source: src, ghost: makeGhost(src), overlay: null, targetLeafId: null, zone: null };
const place = (ev: PointerEvent) => {
if (drag) {
drag.ghost.style.left = `${ev.clientX + 12}px`;
drag.ghost.style.top = `${ev.clientY + 12}px`;
}
};
if (started) place(e);
const move = (ev: PointerEvent) => {
if (!started) {
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) < 5) return;
started = true;
document.body.style.userSelect = "none";
drag = { source: src, ghost: makeGhost(src), overlay: null, targetLeafId: null, zone: null };
}
place(ev);
updateDock(ev);
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
if (!started) return;
document.body.style.userSelect = "";
suppressNextClick();
finishDrag();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
/** Eat the click that follows a real drag (so a dragged sidebar row doesn't
* also navigate, and a dragged pane title doesn't trigger anything). */
function suppressNextClick(): void {
const kill = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
window.removeEventListener("click", kill, true);
};
window.addEventListener("click", kill, true);
}
function paneUnder(x: number, y: number): HTMLElement | null {
for (const node of document.elementsFromPoint(x, y)) {
const pane = (node as HTMLElement).closest?.(".mv-pane") as HTMLElement | null;
if (pane && canvas.contains(pane)) return pane;
}
return null;
}
function clearOverlay(): void {
drag?.overlay?.remove();
if (drag) drag.overlay = null;
}
function updateDock(ev: PointerEvent): void {
if (!drag) return;
clearOverlay();
const pane = paneUnder(ev.clientX, ev.clientY);
const self = dragSelfLeaf(drag.source);
if (!pane || pane.dataset.leafId === self) {
drag.targetLeafId = null;
drag.zone = null;
return;
}
const r = pane.getBoundingClientRect();
const fx = (ev.clientX - r.left) / r.width;
const fy = (ev.clientY - r.top) / r.height;
const edges = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy };
const nearest = (Object.keys(edges) as (keyof typeof edges)[]).reduce((m, k) =>
edges[k] < edges[m] ? k : m,
);
const zone: Zone = edges[nearest] < 0.28 ? nearest : "center";
drag.targetLeafId = pane.dataset.leafId!;
drag.zone = zone;
const ov = document.createElement("div");
ov.className = "mv-dock-overlay";
const hot = document.createElement("div");
hot.className = "mv-dock-zone";
const css: Record<Zone, string> = {
left: "left:0;top:0;width:50%;height:100%",
right: "left:50%;top:0;width:50%;height:100%",
top: "left:0;top:0;width:100%;height:50%",
bottom: "left:0;top:50%;width:100%;height:50%",
center: "inset:14%",
};
hot.style.cssText = "position:absolute;" + css[zone];
ov.appendChild(hot);
pane.appendChild(ov);
drag.overlay = ov;
}
function finishDrag(): void {
if (!drag) return;
const { source, targetLeafId, zone, ghost } = drag;
ghost.remove();
clearOverlay();
drag = null;
if (!targetLeafId || !zone) return;
const tab = activeTab(layout);
const self = dragSelfLeaf(source);
if (targetLeafId === self) return; // dropped on its own pane
const target = findLeaf(tab.root, targetLeafId);
if (!target) return;
const sessionId = dragSessionId(source);
if (zone === "center") {
if (source.kind === "pane" && self) {
const dragged = findLeaf(tab.root, self)!;
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
tab.focusedLeafId = target.id;
} else if (sessionId) {
const loc = locateSession(layout, sessionId);
if (loc) removeLeaf(loc.tab, loc.leaf.id);
const t2 = findLeaf(tab.root, targetLeafId);
if (t2) {
t2.sessionId = sessionId;
tab.focusedLeafId = t2.id;
}
}
render();
return;
}
// edge zone → split the target, placing the source beside it
const dir: Dir = zone === "left" || zone === "right" ? "row" : "col";
const after = zone === "right" || zone === "bottom";
const loc = sessionId ? locateSession(layout, sessionId) : null;
if (source.kind === "pane" && self) removeLeaf(tab, self);
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
equalizeRatios(tab.root);
render();
}
function beginSessionDrag(sessionId: string, e: PointerEvent): void {
dropPreview();
runDrag({ kind: "session", sessionId }, e, { deferred: true });
}
return {
el,
openSession,
openSessionBeside,
closeSession,
openSessions: realOpenSessions,
beginSessionDrag,
render,
focusedSessionId: realFocusedSession,
previewSession,
previewedSessionId: () => preview?.sessionId ?? null,
};
}
-29
View File
@@ -1,29 +0,0 @@
// Shared shape a mounted pane connection satisfies, whatever session view is
// behind it — what ConnectionPool needs to mount/park/dispose a pane without
// caring whether it holds an xterm attach (attach.ts) or a structured
// transcript (structured.ts).
import type { Uuid } from "./types.js";
import type { AttachInfo } from "./paneTitle.js";
export interface PaneConnection {
readonly sessionId: Uuid;
/** The element to mount into a pane. */
readonly el: HTMLElement;
/** Becomes the visible view: fit/focus/sync as the connection needs.
* `focus: false` mounts without taking the keyboard (a sidebar preview). */
onMount(opts?: { focus?: boolean }): void;
/** Parked into the hidden cache: keep streaming, stop driving focus/sizing. */
onUnmount(): void;
/** Put the keyboard into this pane's primary input — the terminal for an
* attach connection, the composer for a structured one. */
focusTerminal(): void;
/** Raw bytes into a PTY, bypassing normal key handling (the leader's
* literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection
* has no PTY, so this is a no-op there. */
sendInput(data: string): void;
/** Refresh the pane header after the catalog renames a live session. */
updateInfo(info: AttachInfo): void;
/** Fully disconnect and remove the element. */
dispose(): void;
}
-28
View File
@@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import { paneTitleAttr, paneTitleHtml } from "./paneTitle.js";
describe("paneTitleHtml", () => {
it("falls back to a short session id when there is no info yet", () => {
expect(paneTitleHtml("abcdef12-3456-7890-abcd-ef1234567890")).toContain("abcdef12");
});
it("shows the name alone when command matches it", () => {
const html = paneTitleHtml("s1", { name: "claude", command: "claude" });
expect(html).toContain("claude");
expect(html).not.toContain("at-cmd");
});
it("escapes a name that looks like markup", () => {
const html = paneTitleHtml("s1", { name: "<img onerror=x>", command: "claude" });
expect(html).not.toContain("<img");
});
});
describe("paneTitleAttr", () => {
it("escapes the attribute form too", () => {
const attr = paneTitleAttr("s1", { name: '"quoted"', command: "claude" });
expect(attr).not.toContain('"quoted"');
expect(attr).toContain("&quot;quoted&quot;");
});
});
-36
View File
@@ -1,36 +0,0 @@
// Pane header title: name/command echoing the sidebar card, shared by the
// xterm attach view (attach.ts) and the structured transcript view
// (structured.ts) so both panes read identically.
import { escapeHtml } from "./html.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { SessionView, Uuid } from "./types.js";
/** What a pane header shows about its session. Falls back to a short session
* id when the session isn't known yet. */
export interface AttachInfo {
name: string;
command: string;
/** Which connection kind to mount (#140). Absent/`"Terminal"` mounts the
* xterm attach; `"Structured"` mounts the transcript view. */
view?: SessionView;
}
function commandFragment(command: string): string {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? "" : program;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
}
export function paneTitleHtml(sessionId: Uuid, info?: AttachInfo): string {
return info
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
}
/** Attribute-safe (`title="..."`) form of the same identity. */
export function paneTitleAttr(sessionId: Uuid, info?: AttachInfo): string {
return escapeHtml(info ? `${info.name} · ${info.command} (${sessionId})` : sessionId);
}
-274
View File
@@ -1,274 +0,0 @@
// @vitest-environment happy-dom
// Sidebar preview (docs/PREVIEW.md): highlighting a session shows it in the
// content pane without changing the layout the operator built.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { addTab, emptyLayout, type Layout, splitLeaf } from "./layout.js";
import { serializeLayout } from "./persistence.js";
import { createMultiView, type MultiView } from "./multiview.js";
import type { ConnectionPool } from "./connections.js";
import type { SidebarHandle } from "./catalog.js";
function stubFetch(): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/info")
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
interface Spy {
pool: ConnectionPool;
visible: () => string[];
focusCalls: (string | null)[];
mountedWithFocus: (boolean | undefined)[];
}
function fakePool(): Spy {
let visible: string[] = [];
const focusCalls: (string | null)[] = [];
const mountedWithFocus: (boolean | undefined)[] = [];
const pool = {
setVisible(entries: { id: string }[], opts?: { focus?: boolean }) {
visible = entries.map((e) => e.id);
mountedWithFocus.push(opts?.focus);
},
setFocus(id: string | null) {
focusCalls.push(id);
},
} as unknown as ConnectionPool;
return { pool, visible: () => visible, focusCalls, mountedWithFocus };
}
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
const previewRow: { id: string | null } = { id: null };
/** Last highlights the controller pushed to `SidebarHandle.setHighlights`. */
const rowHighlights: { open: Set<string>; focused: string | null } = {
open: new Set(),
focused: null,
};
function fakeSidebar(): SidebarHandle {
return {
el: document.createElement("aside"),
setActive() {},
setHighlights(h) {
rowHighlights.open = h.open;
rowHighlights.focused = h.focused;
},
setPreview(id: string | null) {
previewRow.id = id;
},
focusList() {},
hasListFocus() {
return false;
},
dispose() {},
};
}
function makeMv(layout: Layout, pool: ConnectionPool, onLayoutChange?: () => void): MultiView {
const mv = createMultiView({
pool,
layout,
sidebar: fakeSidebar(),
infoFor: () => ({ name: "sess", command: "bash" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
onLayoutChange,
});
document.body.appendChild(mv.el);
mv.render();
return mv;
}
/** Tab 1 holds `s1`; tab 2 holds `s2` and is the active one. */
function twoTabs(): Layout {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
layout.activeTabId = second.id;
return layout;
}
describe("sidebar preview", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
previewRow.id = null;
rowHighlights.open = new Set();
rowHighlights.focused = null;
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("brings a session's own tab forward, then puts the layout back", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s1");
expect(spy.visible()).toEqual(["s1"]);
expect(mv.previewedSessionId()).toBe("s1");
mv.previewSession(null);
expect(spy.visible()).toEqual(["s2"]);
expect(mv.previewedSessionId()).toBeNull();
expect(serializeLayout(layout)).toEqual(before);
});
it("marks the previewed pane and its sidebar row, and unmarks them after", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s1");
expect(previewRow.id).toBe("s1");
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(1);
expect(
(mv.el.querySelector(".mv-pane.is-preview") as HTMLElement).dataset.sessionId,
).toBe("s1");
mv.previewSession(null);
expect(previewRow.id).toBeNull();
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
});
it("leaves the accent on the session the pane actually holds", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
expect(rowHighlights.focused).toBe("s2");
// Walking past other rows never moves the accent off what the operator
// picked: only a real open does (docs/PREVIEW.md).
mv.previewSession("s1");
expect(rowHighlights.focused).toBe("s2");
expect(mv.focusedSessionId()).toBe("s2");
mv.previewSession("s9");
expect(rowHighlights.focused).toBe("s2");
mv.previewSession(null);
expect(rowHighlights.focused).toBe("s2");
mv.openSession("s1");
expect(rowHighlights.focused).toBe("s1");
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s9");
expect(spy.visible()).toEqual(["s9"]);
expect(layout.tabs).toHaveLength(3);
// The throwaway tab is not something the operator has open.
expect(mv.openSessions().has("s9")).toBe(false);
mv.previewSession(null);
expect(layout.tabs).toHaveLength(2);
expect(serializeLayout(layout)).toEqual(before);
});
it("stepping between previews leaves only the newest one on screen", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s9");
mv.previewSession("s1");
mv.previewSession("s8");
expect(spy.visible()).toEqual(["s8"]);
expect(layout.tabs).toHaveLength(3);
mv.previewSession(null);
expect(serializeLayout(layout)).toEqual(before);
});
it("opening what is previewed keeps it: the throwaway tab becomes real", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s9");
mv.openSession("s9");
expect(mv.previewedSessionId()).toBeNull();
expect(layout.tabs).toHaveLength(3);
expect(mv.openSessions().has("s9")).toBe(true);
expect(spy.visible()).toEqual(["s9"]);
// Nothing the operator had open was evicted to make room.
expect(mv.openSessions().has("s2")).toBe(true);
});
it("never persists, and saves again once the preview is settled", () => {
const layout = twoTabs();
const spy = fakePool();
const saves = vi.fn();
const mv = makeMv(layout, spy.pool, saves);
saves.mockClear();
mv.previewSession("s9");
expect(saves).not.toHaveBeenCalled();
mv.previewSession(null);
expect(saves).toHaveBeenCalled();
});
it("never reports a previewed pane as focused to the daemon", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
spy.focusCalls.length = 0;
mv.previewSession("s1");
mv.previewSession("s9");
expect(spy.focusCalls).toEqual([]);
mv.previewSession(null);
expect(spy.focusCalls).toEqual(["s2"]);
});
it("mounts a preview without taking the keyboard", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
spy.mountedWithFocus.length = 0;
mv.previewSession("s1");
expect(spy.mountedWithFocus).toEqual([false]);
mv.openSession("s1");
expect(spy.mountedWithFocus.at(-1)).toBe(true);
});
it("keeps the tab bar hidden while a lone tab is previewing", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
mv.previewSession("s9");
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
expect(mv.el.classList.contains("mv-previewing")).toBe(true);
mv.previewSession(null);
expect(mv.el.classList.contains("mv-previewing")).toBe(false);
});
});
-197
View File
@@ -1,197 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
import type { Project } from "./types.js";
const NOW = "2026-08-23T12:00:00Z";
function project(overrides: Partial<Project>): Project {
return {
path: `/home/user/${overrides.name ?? "proj"}`,
name: "proj",
is_worktree: false,
...overrides,
};
}
/** Three fetches gate the form's initial load: /api/info, /api/projects, and
* the /api/names batch (#53). */
function stubFetch(info: unknown, projects: Project[]): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/info")
? info
: url.includes("/api/projects")
? projects
: url.includes("/api/names")
? { names: ["cello", "fugue", "lyre"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
function projectBoxes(form: HTMLFormElement): HTMLButtonElement[] {
return within(form)
.getAllByRole("button")
.filter((b): b is HTMLButtonElement => b.classList.contains("project-box"));
}
function boxPaths(form: HTMLFormElement): string[] {
return projectBoxes(form).map((b) => b.dataset.cwd ?? "");
}
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
vi.useFakeTimers({ now: Date.parse(NOW) });
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
async function mount(projects: Project[], seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
stubFetch({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }, projects);
handle = createSpawnForm({
seed,
onSuccess() {},
});
document.body.appendChild(handle.el);
for (let i = 0; i < 20; i++) await Promise.resolve();
return handle;
}
it("renders one box per project, fresh before stale", async () => {
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
const stale = project({
name: "stale",
path: "/home/user/stale",
last_activity: "2026-01-01T00:00:00Z",
});
const form = (await mount([stale, fresh])).el;
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
const boxes = projectBoxes(form);
expect(boxes[1].classList.contains("is-stale")).toBe(true);
expect(boxes[0].classList.contains("is-stale")).toBe(false);
});
it("shows the name as the primary line and its parent directory below", async () => {
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
const form = (await mount([p])).el;
const box = within(form).getByRole("button", { name: /nixos-config/ });
expect(within(box).getByText("nixos-config")).toBeTruthy();
// Only the stem: repeating the last segment would just echo the name. Not
// `~/t`, since the stem is what distinguishes two projects sharing a
// basename (#95).
expect(within(box).getByText("~/tea")).toBeTruthy();
});
it("keeps the last command out of the box", async () => {
const p = project({ name: "maestro", path: "/home/user/maestro", last_command: "claude" });
const form = (await mount([p])).el;
const box = within(form).getByRole("button", { name: /maestro/ });
expect(within(box).queryByText(/claude/)).toBeNull();
});
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
const main = project({ name: "maestro", path: "/home/user/maestro" });
const wt = project({
name: "wt",
path: "/home/user/maestro/worktrees/wt",
is_worktree: true,
base_repo: main.path,
});
const form = (await mount([wt, main])).el;
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
expect(form.querySelector(".project-box.is-worktree")).toBeNull();
});
it("filters boxes as the operator types, keeping a repo its worktree matched", async () => {
const a = project({ name: "maestro", path: "/home/user/maestro" });
const b = project({ name: "tea", path: "/home/user/work/tea" });
const wt = project({
name: "bulgarians",
path: "/home/user/maestro/worktrees/bulgarians",
is_worktree: true,
base_repo: a.path,
});
const form = (await mount([a, b, wt])).el;
const filter = within(form).getByPlaceholderText("Filter projects…");
fireEvent.input(filter, { target: { value: "tea" } });
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
// A query that only matches a worktree still surfaces its repository box.
fireEvent.input(filter, { target: { value: "bulgarians" } });
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
fireEvent.input(filter, { target: { value: "zzz" } });
expect(boxPaths(form)).toEqual([]);
expect(within(form).getByText("No projects match.")).toBeTruthy();
});
it("picking a project selects its cwd and leaves the command alone", async () => {
const p = project({
name: "maestro",
path: "/home/user/maestro",
last_command: "aider",
});
const onChange = vi.fn();
stubFetch(
{
hostname: "h",
version: "0",
home_dir: "/home/user",
presets: [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "custom", label: "Custom…", command: "" },
],
},
[p],
);
handle = createSpawnForm({ onSuccess() {}, onChange });
const form = handle.el;
document.body.appendChild(form);
for (let i = 0; i < 20; i++) await Promise.resolve();
onChange.mockClear();
const box = within(form).getByRole("button", { name: /maestro/ });
fireEvent.click(box);
const name = within(form).getByPlaceholderText(
"named after your first prompt",
) as HTMLInputElement;
const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro");
// The directory and the command are independent choices: the preset stays
// where it was, whatever the project was last run with.
expect(last.command).toBe("claude");
// Picking a project is not naming a session: the field stays empty and
// the daemon names it (#131).
expect(name.value).toBe("");
// The raw input stays hidden — the picked box is the source of truth.
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
expect(box.classList.contains("is-active")).toBe(true);
});
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
const p = project({ name: "maestro", path: "/home/user/maestro" });
const form = (await mount([p])).el;
const other = within(form).getByRole("button", { name: "Other directory…" });
fireEvent.click(other);
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
});
it("falls back to the raw input when no projects exist", async () => {
const form = (await mount([])).el;
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
expect(raw.value).toBe("/home/user");
});
});
-174
View File
@@ -1,174 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { renderSidebar } from "./catalog.js";
import { closeContextMenu } from "./contextMenu.js";
import type { CatalogListener, CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import { normalizeSessionName } from "./types.js";
beforeAll(() => {
Object.defineProperty(window, "localStorage", {
value: { getItem: () => null, setItem: () => {} },
});
});
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
afterEach(() => {
closeContextMenu();
mounted?.dispose();
mounted = null;
vi.unstubAllGlobals();
});
function session(overrides: Partial<Session> = {}): Session {
return {
id: "aaaaaaaa-0000-0000-0000-0000000000ff",
cwd: "/home/naps/tea/maestro",
command: "claude",
env: {},
metadata: { name: "amber-falcon" },
status: "Running",
created_at: "2026-08-24T10:00:00Z",
...overrides,
};
}
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;
}
/** Mount a sidebar with one session; the handle is disposed after each test. */
function mount(s: Session): HTMLElement {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const handle = renderSidebar(storeWith([s]));
mounted = handle;
return handle.el;
}
function cardIn(el: HTMLElement, s: Session): HTMLElement {
return el.querySelector<HTMLElement>(`[data-session-id="${s.id}"]`) as HTMLElement;
}
/** Mount a sidebar with one session and right-click its card. */
function openCardMenu(s: Session): { el: HTMLElement; row: HTMLElement } {
const el = mount(s);
const row = cardIn(el, s);
fireEvent.contextMenu(row);
return { el, row };
}
function menuItem(label: string): HTMLElement {
return screen.getByRole("menuitem", { name: label });
}
describe("session name normalisation (#125)", () => {
it("trims, and treats a blank name as a clear", () => {
expect(normalizeSessionName(" amber-falcon ")).toBe("amber-falcon");
expect(normalizeSessionName(" ")).toBe("");
});
it("caps at the daemon's limit, counting code points", () => {
expect(normalizeSessionName("x".repeat(80))).toHaveLength(64);
expect([...normalizeSessionName("é".repeat(80))]).toHaveLength(64);
});
});
describe("sidebar card context menu (#125)", () => {
it("suppresses the browser menu and offers rename, spawn similar and kill", () => {
const s = session();
const row = cardIn(mount(s), s);
const notCancelled = fireEvent.contextMenu(row);
expect(notCancelled).toBe(false);
expect(screen.getAllByRole("menuitem").map((b) => b.textContent)).toEqual([
"Rename…",
"Spawn similar",
"Kill session",
]);
});
it("spawn similar dispatches the same event the ⧉ button does", () => {
const s = session();
openCardMenu(s);
const seen = vi.fn();
document.addEventListener("catalog-spawn-similar", seen);
fireEvent.click(menuItem("Spawn similar"));
document.removeEventListener("catalog-spawn-similar", seen);
expect(seen).toHaveBeenCalledOnce();
});
});
describe("inline rename (#125)", () => {
it("pre-fills the field with the session's own name", () => {
const { row } = openCardMenu(session());
fireEvent.click(menuItem("Rename…"));
const input = within(row).getByRole("textbox") as HTMLInputElement;
expect(input.value).toBe("amber-falcon");
expect(row.querySelector("span.leaf-name")).toBeNull();
});
it("starts empty for a session that has no name of its own", () => {
const { row } = openCardMenu(session({ metadata: {} }));
fireEvent.click(menuItem("Rename…"));
expect((within(row).getByRole("textbox") as HTMLInputElement).value).toBe("");
});
it("holds the tree still while the field is open", () => {
const s = session();
const { el, row } = openCardMenu(s);
fireEvent.click(menuItem("Rename…"));
const input = within(row).getByRole("textbox");
// A catalog delta mid-edit must not rebuild the row out from under it.
fireEvent(document, new CustomEvent("catalog-rerender"));
expect(within(cardIn(el, s)).getByRole("textbox")).toBe(input);
});
it("Enter PATCHes the session with the typed name", () => {
const s = session();
const { row } = openCardMenu(s);
fireEvent.click(menuItem("Rename…"));
const input = within(row).getByRole("textbox") as HTMLInputElement;
fireEvent.input(input, { target: { value: "crimson-otter" } });
const fetchMock = vi.fn(() =>
Promise.resolve(new Response(JSON.stringify(s), { status: 200 })),
);
vi.stubGlobal("fetch", fetchMock);
fireEvent.keyDown(input, { key: "Enter" });
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${s.id}`);
expect(init.method).toBe("PATCH");
expect(JSON.parse(init.body as string)).toEqual({ name: "crimson-otter" });
});
it("Escape cancels without a request and restores the name", () => {
const s = session();
const { el, row } = openCardMenu(s);
fireEvent.click(menuItem("Rename…"));
const input = within(row).getByRole("textbox") as HTMLInputElement;
fireEvent.input(input, { target: { value: "discarded" } });
const fetchMock = vi.fn(() => Promise.reject(new Error("offline")));
vi.stubGlobal("fetch", fetchMock);
fireEvent.keyDown(input, { key: "Escape" });
expect(fetchMock).not.toHaveBeenCalled();
// The row is rebuilt from the store, so the old name comes back.
expect(within(cardIn(el, s)).getByText("amber-falcon")).toBeTruthy();
expect(within(cardIn(el, s)).queryByRole("textbox")).toBeNull();
});
});
-93
View File
@@ -1,93 +0,0 @@
// Blocking overlay for a daemon that went away — see docs/RESTART.md.
//
// Mounted while the link is `down` or `updating`, torn down the moment it is
// back. It owns no polling of its own: DaemonLink polls, and this only renders
// what the link reports.
import { formatRelease, type DaemonLink, type LinkStatus } from "./daemonLink.js";
export interface RestartOverlayHandle {
dispose(): void;
}
/** Mount the overlay controller. It stays subscribed for the page's lifetime. */
export function installRestartOverlay(
link: DaemonLink,
host: HTMLElement = document.body,
): RestartOverlayHandle {
let el: HTMLElement | null = null;
let title: HTMLElement | null = null;
let detail: HTMLElement | null = null;
let release: HTMLElement | null = null;
const unsubscribe = link.subscribe((status) => render(status));
function render(status: LinkStatus): void {
const blocking = status.state === "down" || status.state === "updating";
if (!blocking) {
unmount();
return;
}
if (!el) mount();
const updating = status.state === "updating";
title!.textContent = updating ? "Maestro was updated" : "Maestro is restarting";
detail!.textContent = updating
? "Reloading this page onto the new release…"
: attemptText(status.attempts);
release!.textContent = formatRelease(status.build);
}
/** The poll runs while this is up; saying how many tries have gone by is what
* separates "still restarting" from "wedged" for someone staring at it. */
function attemptText(attempts: number): string {
if (attempts <= 1) return "Waiting for the daemon to come back…";
return `Waiting for the daemon to come back… (${attempts} attempts)`;
}
function mount(): void {
el = document.createElement("div");
el.className = "modal-backdrop restart-backdrop";
const dialog = document.createElement("div");
dialog.className = "modal restart-modal";
dialog.setAttribute("role", "alertdialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-labelledby", "restart-title");
title = document.createElement("h2");
title.id = "restart-title";
detail = document.createElement("p");
detail.className = "restart-detail";
release = document.createElement("p");
release.className = "restart-release";
const spinner = document.createElement("div");
spinner.className = "restart-spinner";
spinner.setAttribute("aria-hidden", "true");
dialog.append(spinner, title, detail, release);
el.appendChild(dialog);
host.appendChild(el);
// The app's shortcuts live on `window`, so hiding the UI behind a backdrop
// does not stop them: `n` would still open a spawn form against a daemon
// that is not there. Swallow keys in the capture phase until the link is
// back, without `preventDefault` so the browser's own chords still work.
window.addEventListener("keydown", swallow, true);
}
function swallow(e: KeyboardEvent): void {
e.stopImmediatePropagation();
}
function unmount(): void {
if (!el) return;
window.removeEventListener("keydown", swallow, true);
el.remove();
el = null;
}
return {
dispose(): void {
unsubscribe();
unmount();
},
};
}
-220
View File
@@ -1,220 +0,0 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { FOCUS_PANE_EVENT, renderSidebar } from "./catalog.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,
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;
}
const A = "aaaaaaaa-0000-0000-0000-00000000000a";
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
/** Two sessions in sibling directories: two folder headers, two cards. */
function twoFolders(opts: Parameters<typeof renderSidebar>[1] = {}): {
el: HTMLElement;
handle: ReturnType<typeof renderSidebar>;
} {
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]), opts);
// Mounted: `focusList` moves real DOM focus, which needs a live document.
document.body.replaceChildren(handle.el);
return { el: handle.el, handle };
}
function press(el: HTMLElement, key: string, init: KeyboardEventInit = {}): void {
fireEvent.keyDown(el, { key, ...init });
}
function highlighted(el: HTMLElement): string | null {
return el.querySelector<HTMLElement>(".is-highlighted")?.dataset.navKey ?? null;
}
function tree(el: HTMLElement): HTMLElement {
return el.querySelector<HTMLElement>(".tree")!;
}
function folderHeaders(el: HTMLElement): HTMLButtonElement[] {
return within(el)
.getAllByRole("button")
.filter((b): b is HTMLButtonElement => b.classList.contains("tree-folder-header"));
}
describe("sidebar keyboard navigation", () => {
it("walks the cards and never stops on an expanded folder", () => {
const { el } = twoFolders();
// The folders are there — the walk just refuses to land on them.
expect(folderHeaders(el)).toHaveLength(2);
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${A}`);
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${B}`);
// Wraps.
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${A}`);
press(tree(el), "k");
expect(highlighted(el)).toBe(`session:${B}`);
});
it("accepts the arrow keys as well as j/k", () => {
const { el } = twoFolders();
press(tree(el), "ArrowDown");
expect(highlighted(el)).toBe(`session:${A}`);
press(tree(el), "ArrowUp");
// Backwards from the first card wraps to the last.
expect(highlighted(el)).toBe(`session:${B}`);
});
it("stops on a collapsed folder, since its cards are gone", () => {
const { el } = twoFolders();
const header = folderHeaders(el)[0];
fireEvent.click(header); // collapse /one
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${B}`);
// Enter expands it again and lands on the card it was hiding.
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
press(tree(el), "Enter");
expect(highlighted(el)).toBe(`session:${A}`);
});
it("focusList starts from the session on screen", () => {
const { el, handle } = twoFolders();
handle.setHighlights({ open: new Set([B]), focused: B });
handle.focusList();
expect(highlighted(el)).toBe(`session:${B}`);
expect(handle.hasListFocus()).toBe(true);
});
it("Tab asks the shell for the pane back", () => {
const { el } = twoFolders();
let asked = 0;
document.addEventListener(FOCUS_PANE_EVENT, () => (asked += 1));
press(tree(el), "Tab");
expect(asked).toBe(1);
});
it("c collapses and expands the highlighted folder", () => {
const { el } = twoFolders();
const header = folderHeaders(el)[0];
fireEvent.click(header); // collapse /one, so the walk stops on its header
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
press(tree(el), "c");
// Expanded: the card it was hiding is back in the walk.
expect(el.querySelector(`[data-nav-key="session:${A}"]`)).not.toBeNull();
});
it("r renames the highlighted session in place", () => {
const { el } = twoFolders();
press(tree(el), "j");
press(tree(el), "r");
const field = within(el).queryByRole("textbox");
expect(field).not.toBeNull();
// Escape settles the rename, so the module-level "a row is being renamed"
// flag doesn't leak into the next test.
fireEvent.keyDown(field!, { key: "Escape" });
});
it("x kills the highlighted session, once confirmed (#160)", async () => {
const { el } = twoFolders();
const killed: string[] = [];
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
killed.push(`${init?.method ?? "GET"} ${String(input)}`);
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof window.fetch;
press(tree(el), "j");
press(tree(el), "x");
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
await Promise.resolve();
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
});
it("leaves the keyboard alone while a row is being renamed", () => {
const { el } = twoFolders();
press(tree(el), "j");
const input = document.createElement("input");
tree(el).appendChild(input);
fireEvent.keyDown(input, { key: "j" });
expect(highlighted(el)).toBe(`session:${A}`);
});
});
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
press(tree(el), "j");
press(tree(el), "j");
expect(seen).toEqual([A, B]);
fireEvent.blur(tree(el));
expect(seen.at(-1)).toBe(null);
});
it("ctrl+hover previews a card, plain hover does not", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
// Plain hover — no preview.
fireEvent.pointerOver(card, { pointerType: "mouse" });
expect(seen).toEqual([]);
// Ctrl+hover — preview fires.
fireEvent.pointerOver(card, { pointerType: "mouse", ctrlKey: true });
expect(seen).toEqual([A]);
// Deduplicated while still on the same card.
fireEvent.pointerOver(card, { pointerType: "mouse", ctrlKey: true });
expect(seen).toHaveLength(1);
fireEvent.pointerLeave(tree(el), { pointerType: "mouse" });
expect(seen.at(-1)).toBe(null);
});
it("ignores touch even with ctrl", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
fireEvent.pointerOver(card, { pointerType: "touch", ctrlKey: true });
expect(seen).toEqual([]);
});
});
-140
View File
@@ -1,140 +0,0 @@
// @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 "@testing-library/jest-dom/vitest";
import { fireEvent, within } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
import { sessionBarCandidates } from "./commandBar.js";
import { matchAndRank } from "./utils/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 = within(handle.el).getByRole("searchbox", {
name: "Search sessions",
}) as HTMLInputElement;
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();
fireEvent.focus(t.search);
expect(t.opens).toHaveLength(1);
t.dispose();
});
it("opens on a tap, where a focus event may never arrive", () => {
const t = mount();
fireEvent.pointerDown(t.search);
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(t.search).not.toHaveFocus();
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")]);
fireEvent.input(t.search, { target: { value: "alpha" } });
// Both cards share one command ("claude") and so one accessible name; the
// DEV-only "kit" link would also match a bare role query for "link".
expect(within(t.handle.el).getAllByRole("link", { name: "claude, running, Idle" })).toHaveLength(2);
t.dispose();
});
it("has no 'No matches' state left: an empty catalog is empty, not filtered", () => {
const t = mount([]);
expect(within(t.handle.el).getByText("No sessions yet")).toBeTruthy();
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);
});
});
-1002
View File
File diff suppressed because it is too large Load Diff
-280
View File
@@ -1,280 +0,0 @@
// @vitest-environment happy-dom
// Enter submits the draft form from any field, at any point (#97). The two
// exceptions: a picker button that isn't yet the active choice takes the
// Enter to *choose* it instead (the next Enter submits), and the project
// filter must resolve to the highlighted project rather than sending
// whatever cwd was already set.
import { fireEvent, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
import type { Project } from "./types.js";
const MAESTRO: Project = {
path: "/home/user/tea/maestro",
name: "maestro",
is_worktree: false,
last_activity: "2026-08-20T10:00:00Z",
};
const WT_A: Project = {
path: "/home/user/tea/maestro/worktrees/bulgarians",
name: "bulgarians",
is_worktree: true,
base_repo: MAESTRO.path,
last_activity: "2026-08-22T10:00:00Z",
};
const OTHER: Project = {
path: "/home/user/tea/rev",
name: "rev",
is_worktree: false,
last_activity: "2026-08-19T10:00:00Z",
};
function stubFetch(projects: Project[]): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/projects")
? projects
: url.includes("/api/names")
? { names: ["amber-fox"] }
: url.includes("/api/branches")
? []
: url.includes("/api/dirs")
? { path: "/opt", entries: ["nomatch-zzz"], truncated: false }
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
describe("spawnForm Enter-to-submit (#97)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
async function mount(
projects: Project[],
seed?: Parameters<typeof createSpawnForm>[0]["seed"],
): Promise<HTMLFormElement> {
stubFetch(projects);
handle = createSpawnForm({ seed, onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
return handle.el;
}
function submitSpy(): ReturnType<typeof vi.fn> {
return vi.fn();
}
function pressEnter(el: HTMLElement): { defaultPrevented: boolean } {
const notCanceled = fireEvent.keyDown(el, { key: "Enter" });
return { defaultPrevented: !notCanceled };
}
/** The step-one project box for `project` — a plain button whose accessible
* name starts with its name, followed by its abbreviated parent path. */
const box = (container: HTMLElement, project: Project): HTMLButtonElement =>
within(container).getByRole("button", { name: new RegExp(`^${project.name}`) }) as HTMLButtonElement;
/** A step-two worktree choice by its label — inside the collapsed "Advanced"
* disclosure, so the role query must opt back in to hidden content. */
const wtChoice = (container: HTMLElement, name: string): HTMLButtonElement =>
within(container).getByRole("button", {
name: new RegExp(`^${name}`),
hidden: true,
}) as HTMLButtonElement;
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
const form = await mount([MAESTRO]);
fireEvent.click(box(form, MAESTRO));
await drain();
const toggle = within(form).getByLabelText("Create git worktree") as HTMLInputElement;
expect(toggle.checked).toBe(true);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(toggle);
expect(toggle.checked).toBe(false);
expect(spy).not.toHaveBeenCalled();
pressEnter(toggle);
expect(toggle.checked).toBe(true);
expect(spy).not.toHaveBeenCalled();
});
describe("submits immediately from ordinary fields", () => {
it("name field", async () => {
const form = await mount([MAESTRO]);
fireEvent.click(box(form, MAESTRO));
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(within(form).getByPlaceholderText("named after your first prompt"));
expect(spy).toHaveBeenCalledTimes(1);
});
it("worktree branch field", async () => {
const form = await mount([MAESTRO]);
fireEvent.click(box(form, MAESTRO));
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(within(form).getByLabelText("New branch name"));
expect(spy).toHaveBeenCalledTimes(1);
});
it("custom command field", async () => {
const form = await mount([MAESTRO]);
fireEvent.click(box(form, MAESTRO));
await drain();
fireEvent.click(within(form).getByRole("button", { name: /Custom/ }));
const raw = within(form).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(raw, { target: { value: "bash -l" } });
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(raw);
expect(spy).toHaveBeenCalledTimes(1);
});
it("raw cwd field once the autocomplete dropdown is closed", async () => {
const form = await mount([MAESTRO]);
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
fireEvent.input(raw, { target: { value: "/opt/xyz-nothing-here" } });
await vi.advanceTimersByTimeAsync(300);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(raw);
expect(spy).toHaveBeenCalledTimes(1);
});
it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => {
const form = await mount([MAESTRO]);
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
raw.focus();
fireEvent.input(raw, { target: { value: "/opt/" } });
await vi.advanceTimersByTimeAsync(150);
const spy = submitSpy();
form.addEventListener("submit", spy);
const { defaultPrevented } = pressEnter(raw);
expect(defaultPrevented).toBe(true);
expect(spy).not.toHaveBeenCalled();
expect(raw.value).toBe("/opt/nomatch-zzz/");
});
});
describe("two-step picker: first Enter chooses, second submits", () => {
it("project box", async () => {
const form = await mount([MAESTRO]);
const b = box(form, MAESTRO);
b.focus();
const spy = submitSpy();
form.addEventListener("submit", spy);
const first = pressEnter(b);
expect(first.defaultPrevented).toBe(true);
expect(b.classList.contains("is-active")).toBe(true);
expect(spy).not.toHaveBeenCalled();
pressEnter(b);
expect(spy).toHaveBeenCalledTimes(1);
});
it("worktree choice", async () => {
const form = await mount([MAESTRO, WT_A]);
fireEvent.click(box(form, MAESTRO));
await drain();
const choice = wtChoice(form, WT_A.name);
expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(choice);
expect(choice.classList.contains("is-active")).toBe(true);
expect(spy).not.toHaveBeenCalled();
pressEnter(choice);
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe("project filter", () => {
it("submits with the highlighted (first matching) project, not the filter text", async () => {
const form = await mount([MAESTRO, OTHER]);
const filter = within(form).getByPlaceholderText("Filter projects…") as HTMLInputElement;
fireEvent.input(filter, { target: { value: "rev" } });
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(box(form, OTHER).classList.contains("is-active")).toBe(true);
});
it("submits in the seeded project rather than the freshest one", async () => {
// What `n` then Enter must do: the modal opens parked on the project of
// the session you were last on (OTHER here), even though MAESTRO's
// worktree is the freshest row in the list.
const form = await mount([MAESTRO, WT_A, OTHER], {
cwd: OTHER.path,
cwdRawVisible: false,
});
expect(box(form, OTHER).classList.contains("is-active")).toBe(true);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(within(form).getByPlaceholderText("Filter projects…"));
expect(spy).toHaveBeenCalledTimes(1);
expect(box(form, OTHER).classList.contains("is-active")).toBe(true);
});
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
const form = await mount([MAESTRO, WT_A]);
fireEvent.click(box(form, MAESTRO));
await drain();
fireEvent.click(wtChoice(form, WT_A.name));
await drain();
const filter = within(form).getByPlaceholderText("Filter projects…") as HTMLInputElement;
fireEvent.input(filter, { target: { value: "maestro" } });
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(wtChoice(form, WT_A.name).classList.contains("is-active")).toBe(true);
});
});
});
-108
View File
@@ -1,108 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
const BATCH_A = ["cello", "fugue", "lyre"];
/** Stub the fetches the form's initial load makes. Records every /api/names
* URL so tests can assert how many handles were asked for. */
function stubFetch(batches: string[][]): string[] {
let batch = 0;
const nameUrls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/names")) {
nameUrls.push(url);
const names = batches[Math.min(batch, batches.length - 1)];
batch += 1;
return new Response(JSON.stringify({ names }), { status: 200 });
}
const body = url.includes("/api/info")
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
: [];
return new Response(JSON.stringify(body), { status: 200 });
}),
);
return nameUrls;
}
describe("spawnForm naming (#131)", () => {
let handle: SpawnFormHandle | null = null;
let nameUrls: string[];
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
async function mount(seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
nameUrls = stubFetch([BATCH_A]);
handle = createSpawnForm({ seed, onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
return handle.el;
}
// Forms from earlier tests in this file are never removed from
// `document.body` (only `destroy()`ed), so lookups are scoped to the form
// under test rather than going through the global `screen`.
const nameOf = (form: HTMLFormElement) =>
(within(form).getByPlaceholderText("named after your first prompt") as HTMLInputElement).value;
const branchOf = (form: HTMLFormElement) =>
(within(form).getByLabelText("New branch name") as HTMLInputElement).value;
it("leaves the name field empty so the daemon names the session", async () => {
const form = await mount();
expect(nameOf(form)).toBe("");
// No reroll control exists in the current markup; kept to guard against
// its (re-)introduction without a matching test update.
expect(form.querySelector(".reroll")).toBeNull();
});
it("still seeds the worktree branch with one generated handle", async () => {
const form = await mount();
expect(BATCH_A).toContain(branchOf(form));
expect(nameUrls).toHaveLength(1);
expect(nameUrls[0]).toContain("count=1");
});
it("keeps a name a clone prefilled, and seeds the branch from it", async () => {
const form = await mount({ name: "my-session" });
expect(nameOf(form)).toBe("my-session");
expect(branchOf(form)).toBe("my-session");
});
it("seeds the branch from a typed name, overriding the fetched handle", async () => {
const form = await mount();
const input = within(form).getByPlaceholderText(
"named after your first prompt",
) as HTMLInputElement;
fireEvent.input(input, { target: { value: "hand-typed" } });
expect(nameOf(form)).toBe("hand-typed");
expect(branchOf(form)).toBe("hand-typed");
});
it("never overwrites a branch the operator typed", async () => {
nameUrls = stubFetch([BATCH_A]);
handle = createSpawnForm({ onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
const branch = within(form).getByLabelText("New branch name") as HTMLInputElement;
fireEvent.input(branch, { target: { value: "feat/mine" } });
for (let i = 0; i < 40; i++) await Promise.resolve();
expect(branchOf(form)).toBe("feat/mine");
});
});
-125
View File
@@ -1,125 +0,0 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
/** Stubs fetch so `/api/info` only resolves once `resolveInfo()` is called —
* everything else (names, projects, gitroot) answers immediately with an
* empty/default body. Lets a test pick a preset (or type a custom command)
* mid-flight, then observe what the late `/api/info` resolution does to it. */
function stubDeferredInfo(): { resolveInfo: () => void } {
let releaseInfo: () => void = () => {};
const infoGate = new Promise<void>((resolve) => {
releaseInfo = resolve;
});
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/info")) {
await infoGate;
return new Response(
JSON.stringify({
hostname: "h",
version: "0",
home_dir: "/home/user",
presets: [
{ id: "claude", label: "Claude", command: "claude" },
{ id: "fish", label: "Fish", command: "fish" },
{ id: "custom", label: "Custom…", command: "" },
],
}),
{ status: 200 },
);
}
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
return new Response(JSON.stringify([]), { status: 200 });
}),
);
return { resolveInfo: releaseInfo };
}
describe("spawnForm preset selection vs. late /api/info (#76)", () => {
let handle: SpawnFormHandle | null = null;
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
});
async function drain(): Promise<void> {
for (let i = 0; i < 40; i++) await Promise.resolve();
}
// Tiles fold their icon's accessible name into the button's computed name,
// so lookups go by the stable `data-id` rather than by accessible role/name.
function tileById(form: HTMLFormElement, id: string): HTMLButtonElement {
return [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === id,
)!;
}
it("keeps an operator-picked tile after /api/info resolves late", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
await drain();
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
const zshTile = tileById(form, "zsh");
fireEvent.click(zshTile);
expect(zshTile).toHaveClass("is-active");
// /api/info lands late with a preset list that doesn't even include "zsh".
resolveInfo();
await drain();
// The operator's pick must not be discarded back to the new list's first
// entry — `onChange` only starts firing once the initial load settles
// (`ready`), which happens right here, so this is the first place the
// published command is observable.
expect(values?.command).toBe("zsh");
});
it("keeps an operator-typed custom command after /api/info resolves late", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
await drain();
fireEvent.click(tileById(form, "custom"));
const cmdRaw = within(form).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(cmdRaw, { target: { value: "htop" } });
resolveInfo();
await drain();
expect(values?.command).toBe("htop");
expect(cmdRaw.value).toBe("htop");
});
it("still applies the real presets' first entry when the operator never touched the command", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
const form = handle.el;
document.body.appendChild(form);
await drain();
resolveInfo();
await drain();
// Untouched: the refined preset list is still free to settle the command.
expect(values?.command).toBe("claude");
expect(tileById(form, "fish")).toBeDefined();
});
});
-148
View File
@@ -1,148 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
/** Debounce window under test — must equal GITROOT_PROBE_DEBOUNCE_MS in
* spawnForm.ts (#74). */
const DEBOUNCE_MS = 250;
const PROJECTS = [
{ path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" },
];
/** Stub the fetches the form makes. Gitroot responses for a given path can be
* held until `releaseGitRoot(path)` is called, so tests can land answers out
* of order. */
function stubFetch(opts?: { holdGitRootFor?: string[] }): {
urls: string[];
gitRootCalls: string[];
releaseGitRoot: (path: string) => void;
} {
const urls: string[] = [];
const gitRootCalls: string[] = [];
const releases = new Map<string, () => void>();
const held = new Map<string, Promise<void>>();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
if (url.includes("/api/dirs")) {
return new Response(JSON.stringify({ entries: [] }), { status: 200 });
}
if (url.includes("/api/gitroot")) {
const path = new URL(url, "http://localhost").searchParams.get("path") ?? "";
gitRootCalls.push(path);
if (opts?.holdGitRootFor?.includes(path)) {
let release!: () => void;
held.set(
path,
new Promise<void>((r) => {
release = r;
}),
);
releases.set(path, release);
}
await held.get(path);
return new Response(JSON.stringify({ root: path }), { status: 200 });
}
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
if (url.includes("/api/projects")) {
return new Response(JSON.stringify(PROJECTS), { status: 200 });
}
return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
{ status: 200 },
);
}),
);
return {
urls,
gitRootCalls,
releaseGitRoot: (path: string) => releases.get(path)?.(),
};
}
describe("spawnForm gitroot probe debounce (#74)", () => {
let handle: SpawnFormHandle | null = null;
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
async function mount(opts?: Parameters<typeof stubFetch>[0]) {
const stub = stubFetch(opts);
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
return { form: handle.el, stub };
}
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
function typeRaw(form: HTMLFormElement, value: string): void {
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
fireEvent.input(raw, { target: { value } });
}
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
const { form, stub } = await mount();
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
// Burst: every keystroke reschedules; nothing may hit /api/gitroot yet.
for (let i = 1; i <= "/opt/somewhere".length; i++) {
typeRaw(form, "/opt/somewhere".slice(0, i));
await drain();
expect(stub.gitRootCalls).toEqual([]);
}
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
await drain();
expect(stub.gitRootCalls).toEqual(["/opt/somewhere"]);
expect(stub.urls.filter((u) => u.includes("/api/gitroot")).length).toBe(1);
});
it("a stale probe answer cannot overwrite a newer input", async () => {
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
// Probe for the old target, held in flight.
typeRaw(form, "/repo/old");
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
// Newer target supersedes it and answers immediately.
typeRaw(form, "/repo/new");
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
await drain();
expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]);
expect(within(form).getByText((content) => content.includes("/repo/new"))).toBeTruthy();
expect((within(form).getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(
true,
);
// The late answer to the old target lands last but must lose.
stub.releaseGitRoot("/repo/old");
await drain();
expect(within(form).getByText((content) => content.includes("/repo/new"))).toBeTruthy();
expect((within(form).getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(
true,
);
});
});
-154
View File
@@ -1,154 +0,0 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
function stubApi(): RequestInit[] {
const sessionRequests: RequestInit[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/api/sessions") && init?.method === "POST") {
sessionRequests.push(init);
return new Response(JSON.stringify({ id: "s1", command: "claude", cwd: "/srv/work" }), {
status: 200,
});
}
if (url.includes("/api/info")) {
return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user" }),
{ status: 200 },
);
}
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
return new Response(JSON.stringify([]), { status: 200 });
}),
);
return sessionRequests;
}
describe("spawn form structured view", () => {
let handle: SpawnFormHandle | null = null;
async function drain(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
afterEach(async () => {
handle?.destroy();
handle = null;
await drain();
vi.unstubAllGlobals();
});
it("is default-on and shown for resolved Claude Code and Codex commands", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
const checkbox = within(handle.el).getByLabelText("Structured view") as HTMLInputElement;
const wrap = checkbox.closest(".structured-view") as HTMLElement;
const toggle = within(handle.el).getByRole("button", { name: /Advanced/ });
expect(toggle).toHaveAttribute("aria-expanded", "false");
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(true);
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
fireEvent.click(custom);
const command = within(handle.el).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(command, { target: { value: "FOO=1 /usr/local/bin/codex" } });
expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(true);
fireEvent.input(command, { target: { value: "opencode" } });
expect(wrap.hidden).toBe(true);
expect(checkbox.checked).toBe(false);
});
it("keeps an unchecked box unchecked across a command change", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
const checkbox = within(handle.el).getByLabelText("Structured view") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
fireEvent.click(handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!);
const command = within(handle.el).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(command, { target: { value: "codex" } });
expect(checkbox.checked).toBe(false);
});
it("clears the checked default when Codex changes to an unsupported command", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
fireEvent.click(handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!);
const command = within(handle.el).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(command, { target: { value: "env -i codex" } });
const checkbox = within(handle.el).getByLabelText("Structured view") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.input(command, { target: { value: "opencode" } });
expect(checkbox.closest<HTMLElement>(".structured-view")!.hidden).toBe(true);
expect(checkbox.checked).toBe(false);
});
it.each(["claude", "codex"])("emits Structured for a %s command by default", async (tool) => {
const requests = stubApi();
handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
onSuccess() {},
});
document.body.appendChild(handle.el);
await drain();
if (tool === "codex") {
fireEvent.click(handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!);
const command = within(handle.el).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(command, { target: { value: "codex" } });
}
fireEvent.submit(handle.el);
await drain();
expect(requests).toHaveLength(1);
expect(JSON.parse(String(requests[0].body))).toMatchObject({
command: tool,
view: "Structured",
});
});
it("emits no view once the operator unticks the box", async () => {
const requests = stubApi();
handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
onSuccess() {},
});
document.body.appendChild(handle.el);
await drain();
fireEvent.click(within(handle.el).getByLabelText("Structured view"));
fireEvent.submit(handle.el);
await drain();
expect(requests).toHaveLength(1);
expect(JSON.parse(String(requests[0].body)).view).toBeUndefined();
});
});
-255
View File
@@ -1,255 +0,0 @@
// @vitest-environment happy-dom
import { fireEvent, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
const PROJECTS = [
{ path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" },
];
const BRANCHES = [
{ name: "main", is_head: true },
{ name: "spawn-v2", is_head: false, worktree: "/home/user/wt-spawn" },
{ name: "free-branch", is_head: false },
];
/** Stub the fetches the form makes. `gateBranches` (when given) holds the
* /api/branches response until released, so tests can destroy the form while
* its branch list is still in flight. */
function stubFetch(opts?: {
gateBranches?: (release: () => void) => void;
projects?: unknown[];
}): string[] {
const urls: string[] = [];
const gated = new Promise<void>((resolve) => {
if (opts?.gateBranches) opts.gateBranches(resolve);
else resolve();
});
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
urls.push(url);
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
if (url.includes("/api/branches")) {
await gated;
return new Response(JSON.stringify(BRANCHES), { status: 200 });
}
if (url.includes("/api/projects")) {
return new Response(JSON.stringify(opts?.projects ?? PROJECTS), { status: 200 });
}
return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
{ status: 200 },
);
}),
);
return urls;
}
describe("spawnForm worktree defaults and branch picker (#61)", () => {
let handle: SpawnFormHandle | null = null;
let urls: string[];
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
handle?.destroy();
handle = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
async function mount(
seed?: Parameters<typeof createSpawnForm>[0]["seed"],
onChange?: (values: SpawnFormValues) => void,
) {
urls = stubFetch();
handle = createSpawnForm({ seed, onSuccess() {}, onChange });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
return handle.el;
}
const drain = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
function pickProject(form: HTMLFormElement): void {
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
fireEvent.click(within(form).getByRole("button", { name: /^maestro/ }));
}
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
const form = await mount();
expect((within(form).getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(
true,
);
pickProject(form);
await drain();
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
expect(within(form).getByText(/\/home\/user\/maestro/)).toBeTruthy();
});
it("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => {
urls = stubFetch({
projects: [
{
path: "/home/user/wt-91",
name: "wt-91",
is_worktree: true,
base_repo: "/home/user/maestro",
last_activity: "2026-08-20T10:00:00Z",
},
],
});
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
fireEvent.click(within(handle.el).getByRole("button", { name: /^wt-91/ }));
await drain();
expect(
(within(handle.el).getByLabelText("Create git worktree") as HTMLInputElement).checked,
).toBe(false);
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
});
it("still probes the gitroot for a free-text directory", async () => {
const form = await mount();
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
// fire — debounced, so the quiet period must elapse first (#74).
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
fireEvent.input(raw, { target: { value: "/opt/somewhere" } });
await vi.advanceTimersByTimeAsync(250);
await drain();
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
});
it("populates existing branches, disabling ones held by another worktree", async () => {
const form = await mount();
pickProject(form);
fireEvent.click(within(form).getByLabelText("Existing branch"));
await drain();
const select = within(form).getByLabelText("Pick a branch") as HTMLSelectElement;
expect(urls.some((u) => u.includes("/api/branches?path="))).toBe(true);
const options = [...select.options];
expect(options.map((o) => o.value)).toEqual(["main", "spawn-v2", "free-branch"]);
const held = options.find((o) => o.value === "spawn-v2")!;
expect(held.disabled).toBe(true);
expect(held.textContent).toContain("/home/user/wt-spawn");
expect(options.find((o) => o.value === "free-branch")!.disabled).toBe(false);
});
it("emits the branch mode as draft state when it changes", async () => {
const changes: SpawnFormValues[] = [];
const form = await mount(undefined, (v) => changes.push(v));
await drain();
fireEvent.click(within(form).getByLabelText("Existing branch"));
await drain();
const modes = changes.map((c) => (c as { worktreeBranchMode?: string }).worktreeBranchMode);
expect(modes).toContain("new");
expect(modes.at(-1)).toBe("existing");
});
it("labels the new-branch input and existing-branch picker distinctly (#94)", async () => {
const form = await mount();
pickProject(form);
await drain();
const newLabel = form.querySelector<HTMLElement>(".wt-new-wrap")!.textContent!.trim();
const existLabel = form.querySelector<HTMLElement>(".wt-exist-wrap")!.textContent!.trim();
expect(newLabel).not.toBe("Branch");
expect(existLabel).not.toBe("Branch");
expect(newLabel).not.toBe(existLabel);
expect(newLabel).toMatch(/name/i);
expect(existLabel).toMatch(/pick|choose|select/i);
});
it("collapses the advanced (worktree) section by default (#93)", async () => {
const form = await mount();
const toggle = within(form).getByRole("button", { name: /Advanced/ });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(true);
});
it("states in the collapsed header that a worktree will be created by default (#93)", async () => {
const form = await mount();
expect(within(form).getByText(/creates? a worktree/i)).toBeTruthy();
});
it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => {
urls = stubFetch({
projects: [
{
path: "/home/user/wt-91",
name: "wt-91",
is_worktree: true,
base_repo: "/home/user/maestro",
last_activity: "2026-08-20T10:00:00Z",
},
],
});
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
fireEvent.click(within(handle.el).getByRole("button", { name: /^wt-91/ }));
await drain();
expect(within(handle.el).getByText(/no worktree/i)).toBeTruthy();
// Still collapsed — picking a project must not force it open.
expect(
within(handle.el).getByRole("button", { name: /Advanced/ }).getAttribute("aria-expanded"),
).toBe("false");
});
it("expanding the advanced section does not reorder the fields above it (#93)", async () => {
const form = await mount();
const before = [...form.querySelectorAll(".field")];
fireEvent.click(within(form).getByRole("button", { name: /Advanced/ }));
expect(
within(form).getByRole("button", { name: /Advanced/ }).getAttribute("aria-expanded"),
).toBe("true");
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(false);
expect([...form.querySelectorAll(".field")]).toStrictEqual(before);
});
it("does not repopulate a destroyed form when the branch list lands late", async () => {
const gate: { release?: () => void } = {};
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
pickProject(handle.el);
fireEvent.click(within(handle.el).getByLabelText("Existing branch"));
handle.destroy();
gate.release?.();
await drain();
expect(
(within(handle.el).getByLabelText("Pick a branch") as HTMLSelectElement).options.length,
).toBe(0);
});
});
-136
View File
@@ -1,136 +0,0 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { closeSpawnModal, openSpawnModal } from "./spawnModal.js";
import type { Session } from "./types.js";
function stubFetch(): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/info")
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
: url.includes("/api/projects")
? []
: url.includes("/api/names")
? { names: ["cello"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
/** Drain the microtask queue so the form's initial loads land. */
async function flush(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
describe("spawn modal", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
closeSpawnModal();
vi.unstubAllGlobals();
});
it("mounts the shared spawn form inside a dialog", async () => {
openSpawnModal({ onSuccess: () => {} });
const dialog = await screen.findByRole("dialog", { name: "Spawn session" });
expect(within(dialog).getByRole("button", { name: "Spawn" })).toBeTruthy();
// The modal adds the Cancel the shared form doesn't ship.
expect(within(dialog).getByRole("button", { name: "Cancel" })).toBeTruthy();
});
it("opens with the project filter focused, harness before project", async () => {
openSpawnModal({ onSuccess: () => {} });
const filterInput = await screen.findByPlaceholderText("Filter projects…");
expect(filterInput).toHaveFocus();
const dialog = screen.getByRole("dialog");
const fieldLabels = within(dialog)
.getAllByText(/.+/, { selector: ".field-label" })
.map((el) => el.textContent);
expect(fieldLabels.slice(0, 2)).toEqual(["Harness", "Project"]);
});
it("titles a seeded open as a clone", async () => {
openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} });
expect(await screen.findByRole("dialog", { name: "Spawn similar" })).toBeTruthy();
});
it("closes on Escape, on Cancel and on a backdrop click", async () => {
for (const dismiss of [
() => fireEvent.keyDown(window, { key: "Escape" }),
() => fireEvent.click(screen.getByRole("button", { name: "Cancel" })),
() => fireEvent.click(screen.getByRole("dialog").parentElement!),
() => fireEvent.click(screen.getByRole("button", { name: "Close" })),
]) {
openSpawnModal({ onSuccess: () => {} });
await screen.findByRole("dialog");
dismiss();
expect(screen.queryByRole("dialog")).toBeNull();
}
});
it("leaves no stray Escape handler behind after closing", async () => {
openSpawnModal({ onSuccess: () => {} });
await screen.findByRole("dialog");
closeSpawnModal();
// A second Escape with no modal up must not throw on the torn-down form.
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("opening twice tears the first one down rather than stacking dialogs", async () => {
openSpawnModal({ onSuccess: () => {} });
await screen.findByRole("dialog");
openSpawnModal({ onSuccess: () => {} });
await screen.findByRole("dialog");
expect(screen.getAllByRole("dialog")).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "Spawn" })).toHaveLength(1);
});
it("closes before handing the created session to its caller", async () => {
const seen: Array<{ session: Session; stillOpen: boolean }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/api/sessions") && init?.method === "POST") {
return new Response(JSON.stringify({ id: "s1", cwd: "/x", command: "zsh" }), { status: 200 });
}
const body = url.includes("/api/info")
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
: url.includes("/api/projects")
? []
: url.includes("/api/names")
? { names: ["cello"] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
openSpawnModal({
// A plain, worktree-free spawn: the point here is where the created
// session is handed over, not what the form validates.
seed: { command: "zsh", cwd: "/x", cwdRawVisible: true, worktreeEnabled: false },
onSuccess: (session) => seen.push({ session, stillOpen: screen.queryByRole("dialog") != null }),
});
await flush();
fireEvent.submit(screen.getByRole("button", { name: "Spawn" }).closest("form")!);
await waitFor(() => expect(seen).toHaveLength(1));
expect(seen[0].session.id).toBe("s1");
expect(seen[0].stillOpen).toBe(false);
});
});
-99
View File
@@ -1,99 +0,0 @@
// The spawn modal: dialog chrome around the shared spawn form (spawnForm.ts).
//
// One modal at a time — opening while one is up replaces it. Escape, the ✕ and
// a click on the backdrop all close it; the form's own Escape handlers (the
// directory autocomplete) swallow the key first, so dismissing a dropdown never
// also discards the form.
import { createSpawnForm, type SpawnFormSeed } from "./spawnForm.js";
import type { Session } from "./types.js";
/** Seed values for the spawn form — used by "Spawn similar" to open the dialog
* with the same cwd, command and name as an existing session. */
export type SpawnPrefill = SpawnFormSeed;
export interface SpawnModalOptions {
seed?: SpawnPrefill;
/** Called with the created session after a successful submit; the modal has
* already closed by then, so the caller decides where it lands. */
onSuccess: (session: Session) => void;
}
/** Closer for the modal currently up, so opening a second one tears the first
* one down properly — dropping its node alone would strand the form's global
* listeners (dir autocomplete) and the Escape handler. */
let closeCurrent: (() => void) | null = null;
export function openSpawnModal(opts: SpawnModalOptions): void {
closeSpawnModal();
const host = document.createElement("div");
host.id = "modal-host";
host.className = "modal-backdrop";
const dialog = document.createElement("div");
dialog.className = "modal spawn-modal";
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-labelledby", "spawn-title");
const header = document.createElement("header");
header.className = "modal-header";
const title = document.createElement("h2");
title.id = "spawn-title";
// "Spawn similar" arrives with a seed; the title says which act this is.
title.textContent = opts.seed ? "Spawn similar" : "Spawn session";
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "btn btn-icon close";
closeBtn.setAttribute("aria-label", "Close");
closeBtn.textContent = "✕";
header.append(title, closeBtn);
dialog.appendChild(header);
host.appendChild(dialog);
document.body.appendChild(host);
const form = createSpawnForm({
seed: opts.seed,
onSuccess: (session) => {
close();
opts.onSuccess(session);
},
});
dialog.appendChild(form.el);
// The project filter owns the keyboard on open: typing narrows the list and
// Tab from here reaches the command tiles, matching the TUI's draft pane.
form.el.querySelector<HTMLInputElement>(".project-filter")?.focus();
function close(): void {
if (closeCurrent === close) closeCurrent = null;
window.removeEventListener("keydown", onKeydown);
form.destroy();
host.remove();
}
closeCurrent = close;
function onKeydown(e: KeyboardEvent): void {
if (e.key === "Escape") {
e.preventDefault();
close();
}
}
// The modal keeps its Cancel affordance; the shared form ships only Submit.
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn btn-ghost cancel";
cancel.textContent = "Cancel";
cancel.addEventListener("click", close);
form.el.querySelector(".form-actions")?.prepend(cancel);
closeBtn.addEventListener("click", close);
host.addEventListener("click", (e) => {
if (e.target === host) close();
});
window.addEventListener("keydown", onKeydown);
}
/** Close whatever spawn modal is up. No-op when none is. */
export function closeSpawnModal(): void {
closeCurrent?.();
}
-160
View File
@@ -1,160 +0,0 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DaemonLink } from "./daemonLink.js";
import { installRestartOverlay } from "./restartOverlay.js";
import { createStatusIndicator } from "./statusIndicator.js";
import type { DaemonInfo } from "./types.js";
import type { Scheduler } from "./events.js";
function info(commit: string, instance: string): DaemonInfo {
return {
hostname: "konishi",
version: "0.1.0",
presets: [],
build: { commit, commit_date: "2026-09-02T14:03:11Z", dirty: false },
instance,
};
}
function manual(): Scheduler & { run(): void } {
let next = 1;
const timers = new Map<number, () => void>();
return {
setTimer(fn) {
const h = next++;
timers.set(h, fn);
return h;
},
clearTimer(h) {
timers.delete(h);
},
run() {
const due = [...timers.values()];
timers.clear();
for (const fn of due) fn();
},
};
}
describe("status indicator", () => {
it("shows the release on the tooltip once the daemon answers", async () => {
const link = new DaemonLink({
fetchInfo: async () => info("f89be4a716c7", "i1"),
scheduler: manual(),
reload: () => {},
});
const ind = createStatusIndicator(link);
await link.start();
expect(ind.el.dataset.state).toBe("online");
// `ind.el` is never appended to the document, so it's queried directly
// rather than through `screen`.
const tip = within(ind.el).getByRole("tooltip").textContent ?? "";
expect(tip).toContain("f89be4a716c7");
expect(tip).toContain("2026-09-02");
expect(tip).toContain("konishi");
expect(ind.el).toHaveAccessibleName(/Connected/);
ind.dispose();
});
it("tracks the link through a drop", async () => {
const sched = manual();
const link = new DaemonLink({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
const ind = createStatusIndicator(link);
expect(ind.el.dataset.state).toBe("connecting");
link.noteDisconnected();
expect(ind.el.dataset.state).toBe("reconnecting");
sched.run();
await Promise.resolve();
expect(ind.el.dataset.state).toBe("down");
ind.dispose();
link.stop();
});
});
describe("restart overlay", () => {
it("blocks once the daemon is unreachable and unlocks when it answers", async () => {
const sched = manual();
let reply: DaemonInfo | null = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload: () => {},
});
const overlay = installRestartOverlay(link);
await link.start();
expect(screen.queryByRole("alertdialog")).toBeNull();
reply = null;
link.noteDisconnected();
expect(screen.queryByRole("alertdialog")).toBeNull(); // grace
sched.run();
await Promise.resolve();
expect(screen.getByRole("alertdialog")).toHaveTextContent("Maestro is restarting");
// Restarted on the same build: no reload, and the overlay goes away.
reply = info("aaa", "i2");
await link.probe();
expect(screen.queryByRole("alertdialog")).toBeNull();
overlay.dispose();
});
it("swallows the app's shortcuts while it is up", async () => {
const sched = manual();
const link = new DaemonLink({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
const overlay = installRestartOverlay(link);
let sawKey = false;
window.addEventListener("keydown", () => {
sawKey = true;
});
link.noteDisconnected();
sched.run();
await Promise.resolve();
fireEvent.keyDown(window, { key: "n" });
expect(sawKey).toBe(false);
overlay.dispose();
link.stop();
fireEvent.keyDown(window, { key: "n" });
expect(sawKey).toBe(true);
});
it("says the page is reloading after an upgrade", async () => {
const sched = manual();
let reply = info("aaa", "i1");
const link = new DaemonLink({
fetchInfo: async () => reply,
scheduler: sched,
reload: () => {},
});
const overlay = installRestartOverlay(link);
await link.start();
link.noteDisconnected();
reply = info("bbb", "i2");
await link.probe();
const dialog = screen.getByRole("alertdialog");
expect(dialog).toHaveTextContent("Maestro was updated");
expect(dialog).toHaveTextContent("bbb");
overlay.dispose();
});
});
-67
View File
@@ -1,67 +0,0 @@
// The sidebar footer's daemon-status dot, sat next to the spawn button.
//
// Colour is the link state; hovering (or focusing — it is a button, so the
// keyboard reaches it) reveals the release the daemon is running: commit hash
// and commit date.
import { formatRelease, type DaemonLink, type LinkState, type LinkStatus } from "./daemonLink.js";
/** One human-readable phrase per link state, used for both the tooltip's first
* line and the button's accessible name. */
const LABELS: Record<LinkState, string> = {
connecting: "Connecting…",
online: "Connected",
reconnecting: "Reconnecting…",
down: "Daemon unreachable",
updating: "Updated — reloading",
};
export interface StatusIndicatorHandle {
el: HTMLElement;
dispose(): void;
}
export function createStatusIndicator(link: DaemonLink): StatusIndicatorHandle {
const el = document.createElement("button");
el.type = "button";
el.className = "btn btn-ghost btn-icon status-chip";
el.setAttribute("aria-describedby", "status-tip");
const dot = document.createElement("span");
dot.className = "status-dot";
dot.setAttribute("aria-hidden", "true");
el.appendChild(dot);
const tip = document.createElement("span");
tip.className = "status-tip";
tip.id = "status-tip";
tip.setAttribute("role", "tooltip");
el.appendChild(tip);
const unsubscribe = link.subscribe((status) => render(status));
function render(status: LinkStatus): void {
const label = LABELS[status.state];
const release = formatRelease(status.build);
el.dataset.state = status.state;
el.setAttribute("aria-label", `${label} — ${release}`);
tip.textContent = "";
tip.append(line("status-tip-state", status.hostname ? `${label} · ${status.hostname}` : label));
tip.append(line("status-tip-release", release));
}
function line(cls: string, text: string): HTMLElement {
const span = document.createElement("span");
span.className = cls;
span.textContent = text;
return span;
}
return {
el,
dispose(): void {
unsubscribe();
el.remove();
},
};
}
+35
View File
@@ -74,6 +74,41 @@ function storeWith(sockets: FakeSocket[], refetch?: () => Promise<Session[]>) {
return { store, scheduler };
}
describe("catalogStore load state", () => {
it("stays unloaded until a list actually lands, so nothing reconciles against an empty map", async () => {
const ws = new FakeSocket();
const { store } = storeWith([ws]);
store.getState().start();
// The socket opening is one message ahead of the snapshot it carries.
ws.open();
expect(store.getState().connected).toBe(true);
expect(store.getState().loaded).toBe(false);
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
expect(store.getState().loaded).toBe(true);
});
it("a REST resync also counts as loaded", async () => {
const ws = new FakeSocket();
const { store } = storeWith([ws], () => Promise.resolve([session("a")]));
store.getState().start();
ws.open();
ws.emit({ type: "Lagged" } as CatalogEvent);
await vi.waitFor(() => expect(store.getState().loaded).toBe(true));
});
it("an empty snapshot still counts as loaded — a daemon with no sessions is a real answer", () => {
const ws = new FakeSocket();
const { store } = storeWith([ws]);
store.getState().start();
ws.open();
ws.emit({ type: "Snapshot", sessions: [] } as CatalogEvent);
expect(store.getState().loaded).toBe(true);
expect(store.getState().sessions.size).toBe(0);
});
});
describe("catalogStore reducer", () => {
it("replaces state on Snapshot and applies deltas", () => {
const ws = new FakeSocket();
+9 -2
View File
@@ -9,6 +9,12 @@ export interface CatalogState {
* — the socket dying is the first sign the daemon went down or was
* upgraded under the page. */
connected: boolean;
/** Whether a full session list has landed yet. `connected` flips when the
* socket opens, which is one message *before* the snapshot it carries — so
* anything that prunes against "sessions the daemon knows about" (boot
* reconciliation, the shell's dead-pane sweep) has to wait on this instead,
* or it reconciles every pane away against an empty map. */
loaded: boolean;
}
/** Opens the events socket. Injectable so the reducer and the backoff schedule
@@ -67,7 +73,7 @@ export function createCatalogStore(opts: CatalogStoreOptions = {}) {
function apply(event: CatalogEvent): void {
switch (event.type) {
case "Snapshot":
set({ sessions: new Map(event.sessions.map((s) => [s.id, s])) });
set({ sessions: new Map(event.sessions.map((s) => [s.id, s])), loaded: true });
return;
case "SessionAdded":
case "SessionUpdated": {
@@ -92,7 +98,7 @@ export function createCatalogStore(opts: CatalogStoreOptions = {}) {
async function resync(): Promise<void> {
try {
const sessions = await refetch();
set({ sessions: new Map(sessions.map((s) => [s.id, s])) });
set({ sessions: new Map(sessions.map((s) => [s.id, s])), loaded: true });
} catch {
// surface in UI on next reconnect cycle; nothing else useful to do
}
@@ -145,6 +151,7 @@ export function createCatalogStore(opts: CatalogStoreOptions = {}) {
return {
sessions: new Map(),
connected: false,
loaded: false,
start() {
connect();

Some files were not shown because too many files have changed in this diff Show More