Compare commits

...
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 2c8956e634 fix(maestro): reset pending state when kill dialog transitions to force mode
ci / web (push) Successful in 38s
ci / flake (push) Successful in 3m51s
ci / rust (push) Has been cancelled
The onConfirm handler in Sidebar resolves (not rejects) when the daemon
refuses an unforced kill — it mutates state to switch to force mode and
returns. That left `pending` stuck at true in the KillDialog, so the
"Force kill" button rendered disabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:24:34 +01:00
Miguel Palhas f2c0882c36 Merge remote-tracking branch 'origin/main' into xylophone-2
ci / web (push) Failing after 6s
ci / flake (push) Successful in 3m40s
ci / rust (push) Has been cancelled
# Conflicts:
#	.claude/settings.local.json
2026-09-10 17:17:32 +01:00
Miguel PalhasandClaude Opus 4.6 c170cd9d60 fix(maestro): restore sidebar footer, fix spawn modal and session card regressions
Post-React-migration regressions: spawn submit button moved to modal
footer, sidebar footer restored (help/theme/palette/status/spawn),
session card stripped of redundant Tailwind, action buttons use proper
leaf-act/leaf-actions CSS structure for hover-only visibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:17:22 +01:00
Miguel PalhasandClaude Opus 4.6 ebf943bf30 chore(maestro): stop tracking settings.local.json
ci / web (push) Successful in 1m3s
ci / flake (push) Successful in 4m2s
ci / rust (push) Successful in 7m14s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:50:31 +01:00
Miguel PalhasandClaude Opus 5 645aa2dea0 fix(maestro): drop border and background on composer stop button
ci / web (push) Failing after 6s
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 16:48:02 +01:00
Miguel Palhas 3a2513c78d Merge branch 'tuba-2'
ci / web (push) Successful in 34s
ci / flake (push) Successful in 5m32s
ci / rust (push) Successful in 8m1s
2026-09-10 16:39:27 +01:00
Miguel Palhas 7cb52927f2 Merge remote-tracking branch 'origin/main' into tuba-2 2026-09-10 16:38:37 +01:00
Miguel PalhasandClaude Opus 4.6 2724447a51 feat(maestro): show last tool and live narration in collapsed group header
The collapsed tool group now reads "N tool calls · Label 'arg'" so the
operator sees the current command at a glance. While a turn is active,
the most recent agent text stays outside the group as visible narration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:38:30 +01:00
Miguel PalhasandClaude Opus 4.6 142e9a4870 chore(maestro): update local tool permissions
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / flake (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:38:24 +01:00
Miguel PalhasandClaude Opus 4.6 e5c17606d0 fix(maestro): update pnpmDeps hash for tailwindcss 4.3.3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:35:37 +01:00
Miguel Palhas 979bfe2fbe chore(maestro): update local tool permissions
ci / web (push) Successful in 47s
ci / flake (push) Failing after 1m5s
ci / rust (push) Successful in 6m44s
2026-09-10 16:25:37 +01:00
Miguel PalhasandClaude Opus 4.6 8f96027878 ci(maestro): cancel in-progress builds on all branches
ci / web (push) Failing after 7s
ci / flake (push) Failing after 1m6s
ci / rust (push) Has been cancelled
Closes #255

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:24:38 +01:00
Miguel Palhas b6f60bfbae Merge remote-tracking branch 'origin/main' into grazioso
ci / web (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / flake (push) Has been cancelled
2026-09-10 16:19:28 +01:00
Miguel PalhasandClaude Opus 4.6 a8a335eadb fix(maestro): remove cancel button border, center attach button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 16:19:24 +01:00
Miguel Palhas 048e273ff9 ci: retry after runner cache clear
ci / web (push) Successful in 1m0s
ci / flake (push) Failing after 2m19s
ci / rust (push) Has been cancelled
2026-09-10 16:18:05 +01:00
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 PalhasandClaude Sonnet 5 c9562ab5eb Closes #252: port vanilla-DOM tests to React Testing Library
Rewrites the 27 DOM-behavior test files (dialogs, sidebar, spawn form,
command bar, structured pane, catalog, multiview, theme, etc.) to use
screen/within/fireEvent/waitFor instead of manual querySelector and
dispatchEvent. Pure-logic test files (matcher, tree, activity, paths,
reconnect, keys, webgl, paneTitle, commands, daemonLink, events, layout,
persistence, previewSchedule, projectList, spawnSimilar, structuredDiff,
toolIcon, fastForward, spawnForm) are untouched, and preview.test.ts
needed no changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 02:06:31 +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
Miguel Palhas 8304ef9a49 Closes #251: port preview mode to React 2026-09-09 01:39:37 +01:00
Miguel Palhas cdba6d07e3 Closes #250: port dialogs, context menu, help overlay, mobile drawer 2026-09-09 01:39:37 +01:00
Miguel Palhas ea8c42bfb2 feat(maestro): port preview mode to React (#251)
Extends layoutStore with previewSession/commitPreview, mirroring
multiview.ts's preview controller: a throwaway tab for a session no pane
holds, byte-for-byte restore on end, and every real layout-changing action
drops an active preview first. Never schedules a save while a preview is up.

Adds usePreview, wrapping the store actions with previewSchedule.ts's
120ms leading-edge throttle, plus a touch guard and an immediate `end()`
for real changes. MultiView/TabBar render the preview hue on the pane
titlebar, canvas ring/chip, and the throwaway tab (dashed, unclickable).
2026-09-09 01:39:05 +01:00
Miguel Palhas e0edbc09d2 feat(maestro): port kill dialog, help overlay, context menu, drawer to React
Closes #250: kill confirmation/force-kill (dialogs/KillDialog), the ?
shortcuts overlay (dialogs/HelpOverlay), the general-purpose right-click
menu (common/ContextMenu, closes on scroll/resize unlike the sidebar's
own), the mobile drawer with swipe support (layout/Drawer), and the
visual-viewport hook (hooks/useViewport). Each is a standalone, tested
port of its vanilla-DOM source; wiring into the shell is a later issue.
2026-09-09 01:33:56 +01:00
Miguel Palhas e9e98e0c49 Closes #249: port layout system to React 2026-09-09 01:23:04 +01:00
Miguel Palhas 02f1844650 Closes #247: port command bar (ctrl-k) to React 2026-09-09 01:23:01 +01:00
Miguel Palhas 1b7ec1f54c feat(maestro): port layout system (tabs, splits, multiview) to React
Closes #249: layoutStore.ts wraps the existing layout.ts tree model
in Zustand, persisting to localStorage on a 400ms debounce and adding
boot reconciliation to prune leaves for sessions no longer live.
MultiView/TabBar/PaneContainer/PaneTitle render tabs, nested splits
with draggable dividers, drag-and-drop tab reordering, and dispatch
each pane to TerminalPane or StructuredPane by session view.

Preview mode (#251) and sidebar drag-to-dock stay out of scope here.
2026-09-09 01:21:37 +01:00
143 changed files with 4792 additions and 18845 deletions
-18
View File
@@ -1,18 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git fetch:*)",
"Bash(git merge:*)",
"Bash(npx tsc:*)",
"Bash(npm run build:*)",
"Bash(npm install:*)",
"Bash(./node_modules/.bin/tsc:*)",
"Bash(npm test:*)",
"Bash(cargo check:*)",
"Bash(cargo clippy:*)",
"Bash(cargo test:*)"
]
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ on:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
+3
View File
@@ -15,3 +15,6 @@ worktrees/
# `nix run .#dev` scratch state (tmux socket)
/.dev/
# Per-machine Claude Code tool permissions
.claude/settings.local.json
+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:
+9 -6
View File
@@ -194,12 +194,15 @@ choices, not daemon ones.
appear *between* tool rows in the same turn are absorbed into the group
(intermediate narration); the last `AgentText` with no subsequent tool stays
outside as the prominent final answer. While a turn is still active
(responding or thinking), the most recent intermediate `AgentText` or
`AgentThought` in the group is kept outside as visible narration — replaced
by the next text row when one arrives, and absorbed into the group when the
turn ends. The header still reads "N tool calls" (counting only tool rows).
Opening the group reveals tools and intermediate text in chronological
order, with the text rendered in a subdued style.
(responding or thinking), the most recent trailing text or thought row is
kept outside as visible narration — the operator always sees the agent's
latest reasoning without opening the group. It is replaced by the next text
row when one arrives, and absorbed into the group when the turn ends (unless
it is `AgentText`, which stays outside as the final answer). The collapsed
header reads "N tool calls · Label 'arg'" where the label and argument come
from the last tool row's `splitToolTitle` — the operator sees which command
is current at a glance. Opening the group reveals tools and intermediate
text in chronological order, with the text rendered in a subdued style.
- The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live
+1 -1
View File
@@ -148,7 +148,7 @@
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-AzF/rrhqbvl613hNsC18YSylBSVxtatg7fEiUEF1u2o=";
hash = "sha256-FEWOAtSFvOWHeCNR0RK8JQ/ssW31qmShu1YD2GuqioQ=";
};
buildPhase = ''
+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>
+442 -2
View File
@@ -1,9 +1,449 @@
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}
linkStore={linkStore}
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 { 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(() => {
document.querySelector(".kill-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(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
"Kill amber-falcon",
);
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
expect(document.querySelector(".kill-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();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("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);
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
folder?.click();
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
raise(RENAME_SESSION_EVENT, s);
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("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);
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
expect(input?.value).toBe("amber-falcon");
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
});
});
-440
View File
@@ -1,440 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeAll, describe, expect, it } from "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.querySelector(".kill-dialog")?.remove();
});
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;
}
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
}
/** 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("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 = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
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 = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
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 = renderSidebar(storeWith([clear, unprobed])).el;
expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull();
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).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 = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(document.querySelector(".kill-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 = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
await flush();
expect(document.querySelector(".kill-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 = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
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 = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file");
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
expect(document.querySelector(".kill-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 = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
expect(document.querySelector(".kill-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 = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
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 = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.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 = renderSidebar(storeWith([s])).el;
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
expect(line1).not.toBeNull();
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
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 = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".card-line2");
expect(line2).not.toBeNull();
expect(row?.querySelector(".activity-rail")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")).toBeNull();
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
});
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 = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".card-line2");
expect(line2?.dataset.activity).toBe("active");
expect(row?.querySelector(".activity-rail")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
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 = renderSidebar(storeWith([generated, haiku, chosen, legacy])).el;
const provisional = (s: Session) =>
rowFor(el, s.id)?.querySelector(".leaf-name")?.classList.contains("is-provisional");
expect(provisional(generated)).toBe(true);
expect(provisional(haiku)).toBe(false);
expect(provisional(chosen)).toBe(false);
expect(provisional(legacy)).toBe(false);
});
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 = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.getAttribute("title")).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 = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
});
it("keeps an unknown command as plain text", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001d",
command: "hx src/main.rs",
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")).toBeNull();
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
});
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 = renderSidebar(storeWith([idle, wait, error])).el;
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"idle",
);
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"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 = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
expect(line2?.getAttribute("data-activity")).toBe("idle");
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
});
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 = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("+124");
expect(line2?.textContent).toContain("-18");
// Line 1 carries no badges.
expect(rowFor(el, s.id)?.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 = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("exited");
});
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 = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement;
const rail = row.querySelector<HTMLElement>(".activity-rail");
expect(rail?.getAttribute("title")).toContain("Awaiting input");
expect(rail?.getAttribute("aria-label")).toContain("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 = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLAnchorElement;
expect(row.getAttribute("href")).toBe("#/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();
},
};
}
-105
View File
@@ -1,105 +0,0 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
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(before.querySelector(".activity-rail")?.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(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
});
});
-723
View File
@@ -1,723 +0,0 @@
// @vitest-environment happy-dom
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 press(key: string, init: KeyboardEventInit = {}): void {
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
}
function type(value: string): void {
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function rowNames(): string[] {
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
}
function activeName(): string | undefined {
return document.querySelector(".cmdbar-row.is-active .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(document.querySelectorAll(".cmdbar-overlay")).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");
const row = document.querySelector(".cmdbar-row") as HTMLElement;
expect(row.dataset.sessionId).toBe("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(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
});
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(document.querySelector(".cmdbar-row-name")?.textContent);
});
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();
(document.querySelector(".cmdbar-overlay") as HTMLElement).dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
expect(isCommandBarOpen()).toBe(false);
});
it("stays open for a press inside the panel", () => {
open();
(document.querySelector(".cmdbar-panel") as HTMLElement).dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
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(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
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 });
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
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 };
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
function rowKeys(): string[][] {
return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) =>
[...el.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 flush();
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 flush();
expect(asked).toEqual(["~/dev/"]);
expect(rowNames()).toEqual(["~/dev/maestro"]);
});
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 flush();
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 flush();
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(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
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(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
});
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 Promise.resolve();
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((document.querySelector(".cmdbar-input") as HTMLInputElement).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((document.querySelector(".cmdbar-input") as HTMLInputElement).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 {
@@ -0,0 +1,68 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ContextMenu } from "./ContextMenu.js";
afterEach(() => {
cleanup();
});
function labels(): string[] {
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
}
describe("ContextMenu (#125)", () => {
it("renders one item per entry, in order", () => {
render(
<ContextMenu
x={10}
y={20}
items={[
{ label: "Rename…", onSelect: () => {} },
{ label: "Kill session", onSelect: () => {}, danger: true },
]}
onClose={vi.fn()}
/>,
);
expect(labels()).toEqual(["Rename…", "Kill session"]);
expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe("Kill session");
});
it("runs the selected entry and closes", () => {
const chosen = vi.fn();
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: chosen }]} onClose={onClose} />);
fireEvent.click(screen.getByRole("menuitem"));
expect(chosen).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
it("closes on Escape and on a press outside it", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent(document.body, new Event("pointerdown", { bubbles: true }));
expect(onClose).toHaveBeenCalledTimes(2);
});
it("stays open for a press inside itself", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
const menu = document.querySelector(".context-menu") as HTMLElement;
fireEvent(menu, new Event("pointerdown", { bubbles: true }));
expect(onClose).not.toHaveBeenCalled();
});
it("closes on scroll and on resize", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
fireEvent.scroll(window);
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.resize(window);
expect(onClose).toHaveBeenCalledTimes(2);
});
});
+86
View File
@@ -0,0 +1,86 @@
// React port of ../../contextMenu.ts (#125), the general-purpose version —
// unlike the sidebar's own menu, this one also closes on scroll/resize, since
// a menu anchored to coordinates goes stale the moment the page moves under it.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
export interface ContextMenuItem {
label: string;
onSelect(): void;
/** Renders the entry in the destructive style. */
danger?: boolean;
}
export interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose(): void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
// Nudge back inside the viewport once the menu has a measurable size.
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
setPos({ left: Math.max(0, Math.min(x, maxX)), top: Math.max(0, Math.min(y, maxY)) });
}, [x, y]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onPointerDown = (e: Event) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
document.addEventListener("keydown", onKeyDown, true);
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("scroll", onClose, true);
window.addEventListener("resize", onClose);
return () => {
document.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("scroll", onClose, true);
window.removeEventListener("resize", onClose);
};
}, [onClose]);
useEffect(() => {
(ref.current?.firstElementChild as HTMLElement | null)?.focus();
}, []);
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: pos.left, top: pos.top }}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
className={`context-menu-item block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
item.danger ? "is-danger text-danger" : "text-ink"
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClose();
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>,
document.body,
);
}
+1
View File
@@ -0,0 +1 @@
export { ContextMenu, type ContextMenuItem, type ContextMenuProps } from "./ContextMenu.js";
@@ -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>
);
}
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { keyCaps } from "../../commands.js";
import { groupedCommands } from "./model.js";
import { HelpOverlay } from "./HelpOverlay.js";
afterEach(() => {
cleanup();
});
function press(key: string, init: KeyboardEventInit = {}): void {
fireEvent.keyDown(window, { key, ...init });
}
describe("HelpOverlay", () => {
it("opens on ? and closes on a second ?", () => {
render(<HelpOverlay />);
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
expect(screen.queryByRole("dialog")).not.toBeNull();
press("?", { shiftKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("ignores ? while typing and when a non-shift modifier is held", () => {
render(<HelpOverlay />);
const input = document.createElement("input");
document.body.appendChild(input);
fireEvent.keyDown(input, { key: "?", shiftKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { ctrlKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
input.remove();
});
it("closes on Escape, on the close button, and on a click outside the panel", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
press("Escape");
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
fireEvent.click(screen.getByRole("dialog"));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("stays open for a click inside the panel", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
fireEvent.click(document.querySelector(".help-panel") as HTMLElement);
expect(screen.queryByRole("dialog")).not.toBeNull();
});
it("renders exactly the commands the registry has bound, grouped", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
const groups = groupedCommands();
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
groups.map((g) => g.title),
);
expect(
[...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId),
).toEqual(groups.flatMap((g) => g.items.map((c) => c.id)));
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
groups.flatMap((g) => g.items.map((c) => c.label)),
);
expect(
[...document.querySelectorAll(".help-row")].map((r) =>
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
),
).toEqual(groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))));
});
});
@@ -0,0 +1,84 @@
// React port of ../../help.ts: the `?` overlay renders the command registry
// (commands.ts) directly — the same list that dispatches the keys — so a
// binding can never be documented as one thing and do another.
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
import { TOGGLE_HELP_EVENT } from "../../uiEvents.js";
import { groupedCommands } from "./model.js";
export function HelpOverlay() {
const [open, setOpen] = useState(false);
useEffect(() => {
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => setOpen((o) => !o) }));
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => setOpen(false) }));
const onKeydown = (e: KeyboardEvent) => {
if (open && overlayKeys.handle(e)) return;
helpKeys.handle(e);
};
const onToggle = () => setOpen((o) => !o);
window.addEventListener("keydown", onKeydown);
document.addEventListener(TOGGLE_HELP_EVENT, onToggle);
return () => {
window.removeEventListener("keydown", onKeydown);
document.removeEventListener(TOGGLE_HELP_EVENT, onToggle);
};
}, [open]);
if (!open) return null;
const close = () => setOpen(false);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
className="help-overlay fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<div className="help-panel max-h-[80vh] w-full max-w-[560px] overflow-auto rounded-r-3 border border-border bg-surface p-sp-4 shadow-2">
<header className="help-head flex items-center justify-between">
<h2 className="help-title m-0 text-fs-15 font-semibold text-ink">Keyboard shortcuts</h2>
<button
type="button"
aria-label="Close"
className="help-close rounded-r-1 px-sp-2 py-1 text-fs-15 text-muted hover:text-ink"
onClick={close}
>
✕
</button>
</header>
{groupedCommands().map((group) => (
<section key={group.title} className="help-group mt-sp-3">
<h3 className="help-group-title text-fs-12 font-semibold text-muted">{group.title}</h3>
<dl className="help-list mt-sp-1">
{group.items.map((cmd) => (
<div
key={cmd.id}
className="help-row flex items-center justify-between gap-sp-3 py-sp-1"
data-command-id={cmd.id}
>
<dt className="help-keys m-0 flex gap-sp-1">
{keyCaps(cmd.keys[0]).map((cap, i) => (
<kbd key={i} className="rounded-r-1 border border-border px-sp-1 text-fs-11">
{cap}
</kbd>
))}
</dt>
<dd className="help-desc m-0 text-fs-12 text-ink">{cmd.label}</dd>
</div>
))}
</dl>
</section>
))}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,103 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { KillDialog } from "./KillDialog.js";
afterEach(() => {
cleanup();
});
describe("KillDialog", () => {
it("focuses the kill button, so Enter confirms", () => {
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={vi.fn()} />);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Kill" }));
});
it("focuses the force button too", () => {
render(
<KillDialog
name="alpha"
blockers={null}
force
reason="worktree is dirty."
onConfirm={() => Promise.resolve()}
onCancel={vi.fn()}
/>,
);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Force kill" }));
});
it("names the blockers in the confirm body", () => {
render(
<KillDialog
name="alpha"
blockers={{ uncommitted_files: 2, untracked_files: 0, unpushed_commits: 0 }}
onConfirm={() => Promise.resolve()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByText(/will be refused unless it is forced/)).not.toBeNull();
});
it("cancels on Cancel, on Escape and on a backdrop click", () => {
const onCancel = vi.fn();
const first = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
first.unmount();
const second = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(2);
second.unmount();
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("dialog"));
expect(onCancel).toHaveBeenCalledTimes(3);
});
it("disables the button while pending and shows a rejection inline, so the press can be retried", async () => {
let reject: (e: Error) => void = () => {};
const onConfirm = vi.fn(() => new Promise<void>((_, r) => (reject = r)));
render(<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />);
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
fireEvent.click(kill);
expect(onConfirm).toHaveBeenCalledOnce();
expect(kill.disabled).toBe(true);
reject(new Error("daemon unreachable"));
await waitFor(() => expect(kill.disabled).toBe(false));
expect(screen.getByText("Failed to kill: daemon unreachable")).not.toBeNull();
});
it("resets pending when force changes, so the force button is clickable", async () => {
// Simulate the Sidebar flow: onConfirm resolves (parent switches to
// force mode via rerender), but pending was set to true before the call.
const onConfirm = vi.fn(() => Promise.resolve());
const { rerender } = render(
<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />,
);
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
fireEvent.click(kill);
expect(kill.disabled).toBe(true);
// Parent re-renders with force=true (the daemon refused the unforced kill).
await waitFor(() => expect(onConfirm).toHaveBeenCalledOnce());
rerender(
<KillDialog
name="alpha"
blockers={{ uncommitted_files: 1, untracked_files: 0, unpushed_commits: 0 }}
force
reason="worktree is dirty"
onConfirm={onConfirm}
onCancel={vi.fn()}
/>,
);
const forceBtn = screen.getByRole("button", { name: "Force kill" }) as HTMLButtonElement;
expect(forceBtn.disabled).toBe(false);
});
});
+100
View File
@@ -0,0 +1,100 @@
// React port of ../../killDialog.ts: the two kill prompts (#81, #160). The
// daemon refuses an unforced kill over a worktree holding unsaved work; the
// same component in `force` mode is the override that follows that refusal.
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { formatInTheWay, type InTheWay } from "../../types.js";
export interface KillDialogProps {
/** Session name, as the sidebar shows it. */
name: string;
/** What the card last saw in the way, when anything was. */
blockers: InTheWay | null;
/** Set once the daemon has refused an unforced kill: this pass forces it,
* and `reason` carries the daemon's refusal message. */
force?: boolean;
/** Message from the daemon's refusal, shown when there are no blockers to
* spell out. */
reason?: string;
/** Run the kill (or the forced kill). A rejection is rendered inline, and
* the dialog stays open so the press can be retried. */
onConfirm(): Promise<void>;
onCancel(): void;
}
export function KillDialog(props: KillDialogProps) {
const { name, blockers, force, reason, onConfirm, onCancel } = props;
const confirmRef = useRef<HTMLButtonElement>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
// The acting button takes the focus in both modes: the operator already
// asked for the kill, so Enter confirms it.
useEffect(() => confirmRef.current?.focus(), []);
// When the dialog transitions from normal → force mode, the previous
// onConfirm resolved without error (it mutated parent state and returned),
// so `pending` is still true. Reset it so the force button is clickable.
useEffect(() => setPending(false), [force]);
const handleConfirm = () => {
setPending(true);
setError(null);
void onConfirm().catch((e: unknown) => {
setPending(false);
setError(`Failed to kill: ${(e as Error).message}`);
});
};
const body = blockers
? force
? `Its worktree still has ${formatInTheWay(blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `Its worktree still has ${formatInTheWay(blockers)}. The kill will be refused unless it is forced.`
: force
? `${reason ?? ""} Forcing the kill removes the session and leaves the worktree on disk.`
: "The session and everything running in it go away. Its worktree stays on disk.";
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={`Kill ${name}`}
className="kill-dialog fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
>
<div className="kill-dialog-panel min-w-[280px] max-w-[420px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
<h2 className="kill-dialog-title m-0 text-fs-15 font-semibold text-ink">Kill &quot;{name}&quot;?</h2>
<p className="kill-dialog-body mt-sp-2 text-fs-13 text-ink">{body}</p>
{error && <p className="kill-dialog-error mt-sp-2 text-fs-12 text-danger">{error}</p>}
<div className="kill-dialog-actions mt-sp-3 flex justify-end gap-sp-2">
<button
type="button"
className="kill-dialog-cancel rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
onClick={onCancel}
>
Cancel
</button>
<button
ref={confirmRef}
type="button"
disabled={pending}
className={`rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink disabled:opacity-60 ${
force ? "kill-dialog-force" : "kill-dialog-kill"
}`}
onClick={handleConfirm}
>
{force ? "Force kill" : "Kill"}
</button>
</div>
</div>
</div>,
document.body,
);
}
+3
View File
@@ -0,0 +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;
}
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useViewport } from "./useViewport.js";
function Probe() {
useViewport();
return null;
}
class FakeVisualViewport {
height = 800;
offsetTop = 0;
private listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
addEventListener(type: string, cb: () => void): void {
this.listeners[type].push(cb);
}
removeEventListener(type: string, cb: () => void): void {
this.listeners[type] = this.listeners[type].filter((l) => l !== cb);
}
fire(type: "resize" | "scroll"): void {
for (const l of this.listeners[type]) l();
}
}
let vv: FakeVisualViewport;
beforeEach(() => {
vv = new FakeVisualViewport();
vi.stubGlobal("visualViewport", vv);
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
document.documentElement.className = "";
document.documentElement.removeAttribute("style");
});
describe("useViewport", () => {
it("mirrors the visual viewport's height and offset onto CSS custom properties", () => {
vv.height = 640;
vv.offsetTop = 12;
render(<Probe />);
const root = document.documentElement.style;
expect(root.getPropertyValue("--app-vh")).toBe("640px");
expect(root.getPropertyValue("--app-top")).toBe("12px");
});
it("no-ops when visualViewport is unavailable", () => {
vi.stubGlobal("visualViewport", undefined);
expect(() => render(<Probe />)).not.toThrow();
expect(document.documentElement.style.getPropertyValue("--app-vh")).toBe("");
});
it("marks kb-open once an editable shrinks the viewport well past the baseline", () => {
render(<Probe />);
const input = document.createElement("input");
document.body.appendChild(input);
input.focus();
vv.height = 800 - 200;
vv.fire("resize");
expect(document.documentElement.classList.contains("kb-open")).toBe(true);
input.blur();
vv.height = 800;
vv.fire("resize");
expect(document.documentElement.classList.contains("kb-open")).toBe(false);
input.remove();
});
});
+51
View File
@@ -0,0 +1,51 @@
// React port of ../../viewport.ts. Binds the app shell's height and position
// to the *visual* viewport rather than the layout one, so a pinned bottom bar
// (the attach view's special-key bar, NAP-342) lands above an open on-screen
// keyboard instead of behind it. See viewport.ts for the full mechanism.
import { useEffect } from "react";
const KB_OPEN_THRESHOLD = 150;
export function useViewport(): void {
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const root = document.documentElement;
let raf = 0;
let baseline = vv.height;
const isEditing = () => {
const a = document.activeElement as HTMLElement | null;
if (!a) return false;
return a.tagName === "TEXTAREA" || a.tagName === "INPUT" || a.isContentEditable;
};
const sync = () => {
raf = 0;
root.style.setProperty("--app-vh", `${Math.round(vv.height)}px`);
root.style.setProperty("--app-top", `${Math.round(vv.offsetTop)}px`);
// Nothing focused ⇒ keyboard can't be open ⇒ this height is the baseline.
const editing = isEditing();
if (!editing) baseline = vv.height;
const kbOpen = editing && baseline - vv.height > KB_OPEN_THRESHOLD;
root.classList.toggle("kb-open", kbOpen);
};
const schedule = () => {
if (raf) return;
raf = requestAnimationFrame(sync);
};
vv.addEventListener("resize", schedule);
vv.addEventListener("scroll", schedule);
sync();
return () => {
vv.removeEventListener("resize", schedule);
vv.removeEventListener("scroll", schedule);
if (raf) cancelAnimationFrame(raf);
};
}, []);
}
+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>
);
}
+154
View File
@@ -0,0 +1,154 @@
// @vitest-environment happy-dom
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 "../../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
* (mirrors ../layout/MultiView's own `forceMobile` in multiview.test.ts). */
function forceMobile(matches: boolean): void {
vi.spyOn(window, "matchMedia").mockImplementation(
(query: string) =>
({
matches: query.includes("859") && matches,
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList,
);
}
function shell(): HTMLElement {
return document.querySelector(".app-shell") as HTMLElement;
}
function touch(type: "touchstart" | "touchend", x: number, y: number): void {
const touches = [{ clientX: x, clientY: y }];
const event = new Event(type, { bubbles: true }) as unknown as {
touches: typeof touches;
changedTouches: typeof touches;
};
event.touches = touches;
event.changedTouches = touches;
act(() => {
document.dispatchEvent(event as unknown as Event);
});
}
beforeEach(() => forceMobile(true));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function openViaToggle(): void {
act(() => {
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
});
}
describe("Drawer (#35)", () => {
it("toggles open on the toggle event, off on desktop by class alone", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
expect(shell().classList.contains("drawer-open")).toBe(false);
openViaToggle();
expect(shell().classList.contains("drawer-open")).toBe(true);
});
it("closes when a session row is tapped", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
fireEvent.click(document.querySelector(".tree-leaf") as HTMLElement);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes when a spawn is requested", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
act(() => {
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
});
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes when spawn similar is requested", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
act(() => {
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
});
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes on a backdrop click", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
fireEvent.click(document.querySelector(".mv-backdrop") as HTMLElement);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("opens on a rightward swipe and closes on a leftward one, only on mobile", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
touch("touchstart", 0, 100);
touch("touchend", 100, 100);
expect(shell().classList.contains("drawer-open")).toBe(true);
touch("touchstart", 100, 100);
touch("touchend", 0, 100);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("ignores swipes on desktop", () => {
forceMobile(false);
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
touch("touchstart", 0, 100);
touch("touchend", 100, 100);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("ignores a mostly-vertical or too-short gesture", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
touch("touchstart", 0, 0);
touch("touchend", 10, 100);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
});
+92
View File
@@ -0,0 +1,92 @@
// React port of ../../drawer.ts (#35): the mobile off-canvas sidebar. On
// desktop the `drawer-open` class is inert — CSS gates the drawer styling to
// narrow viewports (styles.css, `.app-shell`).
//
// 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 — this
// listens for the same TOGGLE_DRAWER_EVENT/OPEN_SPAWN_EVENT/SPAWN_SIMILAR_EVENT
// the rest of the shell already raises, so it needs no wiring from callers.
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT, TOGGLE_DRAWER_EVENT } from "../../uiEvents.js";
export interface DrawerProps {
sidebar: ReactNode;
children: ReactNode;
className?: string;
}
export function Drawer({ sidebar, children, className }: DrawerProps) {
const [open, setOpen] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onToggle = () => setOpen((o) => !o);
const onClose = () => setOpen(false);
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
document.addEventListener(OPEN_SPAWN_EVENT, onClose);
document.addEventListener(SPAWN_SIMILAR_EVENT, onClose);
return () => {
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
document.removeEventListener(OPEN_SPAWN_EVENT, onClose);
document.removeEventListener(SPAWN_SIMILAR_EVENT, onClose);
};
}, []);
// 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.
useEffect(() => {
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
const onTouchStart = (e: TouchEvent) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
};
const onTouchEnd = (e: TouchEvent) => {
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;
setOpen((o) => {
if (dx > 0 && !o) return true;
if (dx < 0 && o) return false;
return o;
});
};
document.addEventListener("touchstart", onTouchStart, { passive: true });
document.addEventListener("touchend", onTouchEnd, { passive: true });
return () => {
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("touchend", onTouchEnd);
};
}, []);
return (
<div className={`app-shell flex h-full min-h-0 ${open ? "drawer-open" : ""} ${className ?? ""}`}>
<div
ref={sidebarRef}
className="sidebar flex h-full min-h-0 flex-col bg-surface"
onClick={(e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) setOpen(false);
}}
>
{sidebar}
</div>
<section className="content min-h-0 flex-1">{children}</section>
<div className="mv-backdrop" onClick={() => setOpen(false)} />
</div>
);
}
@@ -0,0 +1,152 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { leaves } from "../../layout.js";
import { createLayoutStore } from "../../stores/layoutStore.js";
import type { Session } from "../../types.js";
import { MultiView } from "./MultiView.js";
vi.mock("../terminal/TerminalPane.js", () => ({
TerminalPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`terminal-${sessionId}`} />,
}));
vi.mock("../structured/StructuredPane.js", () => ({
StructuredPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`structured-${sessionId}`} />,
}));
afterEach(cleanup);
function store() {
return createLayoutStore({
load: () => null,
save: vi.fn(),
scheduler: { setTimer: () => 1, clearTimer: () => {} },
});
}
function session(id: string, over: Partial<Session> = {}): Session {
return {
id,
cwd: "/home/n",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
} as Session;
}
describe("MultiView", () => {
it("shows the empty state when the active tab has no panes", () => {
const s = store();
render(<MultiView store={s} />);
expect(screen.getByText(/No session open/)).toBeTruthy();
});
it("renders a pane per leaf, dispatching to the terminal or structured body", () => {
const s = store();
s.getState().openSession("s1");
s.getState().openSessionBeside("s2");
const sessions = new Map([
["s1", session("s1")],
["s2", session("s2", { view: "Structured" })],
]);
render(<MultiView store={s} sessions={sessions} />);
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
expect(screen.getByTestId("structured-s2")).toBeTruthy();
});
it("split right adds a second pane to the tree", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
fireEvent.click(screen.getByTitle("Split right"));
expect(screen.getAllByText("Empty pane")).toHaveLength(1);
});
it("close pane removes it", () => {
const s = store();
s.getState().openSession("s1");
const leafId = leaves(s.getState().layout.tabs[0].root)[0].id;
s.getState().splitFocused(leafId, "row"); // adds an empty leaf beside s1
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
// splitFocused(after: true) puts the fresh empty leaf second — close it.
fireEvent.click(screen.getAllByTitle("Close pane")[1]);
expect(screen.queryByText("Empty pane")).toBeNull();
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
});
it("prunes a leaf whose session no longer exists, once the live set is known", () => {
const s = store();
s.getState().openSession("s1");
s.getState().openSessionBeside("dead");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
expect(screen.queryByTestId("terminal-dead")).toBeNull();
});
it("does not touch the layout while the live set is still unknown", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} />);
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
});
describe("preview mode (docs/PREVIEW.md)", () => {
it("shows a session no pane holds in a throwaway tab, marked as a preview", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
act(() => s.getState().previewSession("s9"));
// s1's own tab is still in the strip — the preview never touched it.
expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
const pane = document.querySelector('[data-session-id="s9"]');
expect(pane).toBeTruthy();
expect(pane!.querySelector(".bg-preview-tint")).toBeTruthy();
expect(screen.getByText("preview")).toBeTruthy();
});
it("puts the layout back byte for byte once the preview ends", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
const before = JSON.stringify(s.getState().layout);
act(() => s.getState().previewSession("s9"));
act(() => s.getState().previewSession(null));
expect(JSON.stringify(s.getState().layout)).toBe(before);
expect(screen.queryByText("preview")).toBeNull();
});
it("does not grab the keyboard while previewing", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
act(() => s.getState().previewSession("s9"));
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
expect(pane.querySelector('[data-testid="terminal-s9"]')).toBeTruthy();
// TerminalPane's `focus` prop is verified by TerminalPane's own tests;
// here the preview must never end up as the active-tab's accent pane.
expect(pane.className).not.toContain("border-accent");
});
it("clicking into a previewed pane commits it: no more preview, nothing moves a second time", () => {
const s = store();
s.getState().openSession("s1");
render(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
act(() => s.getState().previewSession("s9"));
const pane = document.querySelector('[data-session-id="s9"]') as HTMLElement;
fireEvent.pointerDown(pane);
expect(s.getState().preview).toBeNull();
expect(screen.queryByText("preview")).toBeNull();
expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s9"]);
});
});
});
+271
View File
@@ -0,0 +1,271 @@
// React port of multiview.ts's tab + split tree (issue #249) plus its preview
// mode (issue #251, docs/PREVIEW.md). Sidebar drag-to-dock and the mobile
// single-pane collapse stay with whichever issue wires this into the shell
// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
import type { ReactNode } 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, 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
* reconciliation. Passing `undefined` defers reconciliation. */
sessions?: ReadonlyMap<Uuid, Session>;
onDisconnect?: (sessionId: Uuid) => void;
emptyState?: ReactNode;
}
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;
const reconciledRef = useRef(false);
useEffect(() => {
if (reconciledRef.current || !sessions) return;
reconciledRef.current = true;
store.getState().reconcile(new Set(sessions.keys()));
}, [sessions, store]);
return (
<div className="mv flex h-full min-h-0 flex-col">
<TabBar store={store} />
<div className="mv-canvas relative min-h-0 flex-1">
{tab.root ? (
<SplitNode
node={tab.root}
tab={tab}
panes={panes}
store={store}
sessions={sessions}
onDisconnect={onDisconnect}
previewedSessionId={previewedSessionId}
commitPreview={commitPreview}
/>
) : (
(emptyState ?? (
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
No session open — pick one from the sidebar, or spawn a new one.
</div>
))
)}
{isPreviewing && (
<>
<div className="mv-preview-ring pointer-events-none absolute inset-0 rounded-r-2 border border-dashed border-preview-ring" />
<div className="mv-preview-chip pointer-events-none absolute right-sp-2 top-sp-2 z-sticky rounded-r-pill bg-preview px-sp-2 py-[2px] text-fs-11 text-preview-ink">
preview
</div>
</>
)}
</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;
previewedSessionId: Uuid | null;
commitPreview: (sessionId: Uuid) => void;
}
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
if (node.kind === "leaf") {
return <Pane leaf={node} isFocused={rest.tab.focusedLeafId === node.id} {...rest} />;
}
return <SplitContainer node={node} {...rest} />;
}
function SplitContainer({ node, ...rest }: TreeProps & { node: Extract<Node, { kind: "split" }> }) {
const slotA = useRef<HTMLDivElement | null>(null);
const slotB = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const onDividerPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const divider = e.currentTarget;
const container = containerRef.current;
if (!container) return;
divider.setPointerCapture(e.pointerId);
const rect = container.getBoundingClientRect();
const move = (ev: PointerEvent) => {
const frac =
node.dir === "row" ? (ev.clientX - rect.left) / rect.width : (ev.clientY - rect.top) / rect.height;
node.ratio = Math.max(0.1, Math.min(0.9, frac));
if (slotA.current) slotA.current.style.flexGrow = String(node.ratio);
if (slotB.current) slotB.current.style.flexGrow = String(1 - node.ratio);
};
const up = (ev: PointerEvent) => {
try {
divider.releasePointerCapture(ev.pointerId);
} catch {
// already released
}
divider.removeEventListener("pointermove", move);
divider.removeEventListener("pointerup", up);
rest.store.getState().commitLayout();
};
divider.addEventListener("pointermove", move);
divider.addEventListener("pointerup", up);
};
return (
<div
ref={containerRef}
className={`mv-split flex h-full min-h-0 w-full ${node.dir === "row" ? "flex-row" : "flex-col"}`}
>
<div ref={slotA} className="mv-slot min-h-0 min-w-0" style={{ flex: `${node.ratio} 1 0` }}>
<SplitNode node={node.a} {...rest} />
</div>
<div
className={
"mv-divider flex-none bg-border-strong hover:bg-accent " +
(node.dir === "row" ? "w-[3px] cursor-col-resize" : "h-[3px] cursor-row-resize")
}
onPointerDown={onDividerPointerDown}
/>
<div ref={slotB} className="mv-slot min-h-0 min-w-0" style={{ flex: `${1 - node.ratio} 1 0` }}>
<SplitNode node={node.b} {...rest} />
</div>
</div>
);
}
function Pane({
leaf,
isFocused,
panes,
store,
sessions,
onDisconnect,
previewedSessionId,
commitPreview,
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
const isPreview = leaf.sessionId !== null && leaf.sessionId === previewedSessionId;
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
return (
<div
className={
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
(isFocused && !isPreview ? "border-accent" : "border-border-strong")
}
data-leaf-id={leaf.id}
data-session-id={leaf.sessionId ?? undefined}
onPointerDownCapture={() => {
// Clicking into a previewed pane is the operator choosing it
// (docs/PREVIEW.md).
if (isPreview) commitPreview(leaf.sessionId!);
store.getState().focusLeaf(leaf.id);
}}
>
<div
className={
"mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12 " +
(isPreview ? "bg-preview-tint text-preview" : "bg-surface")
}
>
{leaf.sessionId ? (
<PaneTitle sessionId={leaf.sessionId} session={session} />
) : (
<span className="text-faint">Empty pane</span>
)}
<div className="mv-pane-tools flex items-center gap-sp-1">
{leaf.sessionId && onDisconnect && (
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Disconnect"
onClick={() => onDisconnect(leaf.sessionId!)}
>
⏻
</button>
)}
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Split right"
onClick={() => splitFocused("row")}
>
▥
</button>
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Split down"
onClick={() => splitFocused("col")}
>
▤
</button>
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Close pane"
onClick={() => store.getState().closePane(leaf.id)}
>
✕
</button>
</div>
</div>
<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}
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
/>
) : (
<div className="mv-pane-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
Empty pane — pick a session from the sidebar, or spawn one.
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Session } from "../../types.js";
import { PaneContainer } from "./PaneContainer.js";
vi.mock("../terminal/TerminalPane.js", () => ({
TerminalPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
<div data-testid="terminal-pane" data-session-id={sessionId} data-focus={String(focus)} />
),
}));
vi.mock("../structured/StructuredPane.js", () => ({
StructuredPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
<div data-testid="structured-pane" data-session-id={sessionId} data-focus={String(focus)} />
),
}));
afterEach(cleanup);
function session(over: Partial<Session> = {}): Session {
return {
id: "s1",
cwd: "/home/n",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
} as Session;
}
describe("PaneContainer", () => {
it("renders TerminalPane when the session has no view (defaults to Terminal)", () => {
render(<PaneContainer sessionId="s1" session={session()} />);
expect(screen.getByTestId("terminal-pane")).toBeTruthy();
expect(screen.queryByTestId("structured-pane")).toBeNull();
});
it("renders TerminalPane when the session isn't known yet", () => {
render(<PaneContainer sessionId="s1" />);
expect(screen.getByTestId("terminal-pane")).toBeTruthy();
});
it("renders StructuredPane for a Structured session", () => {
render(<PaneContainer sessionId="s1" session={session({ view: "Structured" })} focus={false} />);
const pane = screen.getByTestId("structured-pane");
expect(pane.dataset.sessionId).toBe("s1");
expect(pane.dataset.focus).toBe("false");
expect(screen.queryByTestId("terminal-pane")).toBeNull();
});
});
@@ -0,0 +1,49 @@
// Dispatches a leaf's session to the right body — TerminalPane for a PTY
// session, StructuredPane for an ACP one (session.view, #133/#140/#249).
import { forwardRef, useImperativeHandle, useRef } from "react";
import type { Session, Uuid } from "../../types.js";
import { StructuredPane, type StructuredPaneHandle } from "../structured/StructuredPane.js";
import { TerminalPane, type TerminalPaneHandle } from "../terminal/TerminalPane.js";
export interface PaneContainerHandle {
focusTerminal(): void;
/** No-op for a structured pane — it has no PTY (pane.ts's PaneConnection contract). */
sendInput(data: string): void;
}
export interface PaneContainerProps {
sessionId: Uuid;
session?: Session;
/** Grabs the keyboard once connected. A sidebar preview passes `false`. */
focus?: boolean;
onClosed?: () => void;
className?: string;
}
export const PaneContainer = forwardRef<PaneContainerHandle, PaneContainerProps>(function PaneContainer(
{ sessionId, session, focus, onClosed, className },
ref,
) {
const terminalRef = useRef<TerminalPaneHandle>(null);
const structuredRef = useRef<StructuredPaneHandle>(null);
useImperativeHandle(
ref,
() => ({
focusTerminal: () => {
terminalRef.current?.focusTerminal();
structuredRef.current?.focusComposer();
},
sendInput: (data: string) => terminalRef.current?.sendInput(data),
}),
[],
);
return session?.view === "Structured" ? (
<StructuredPane ref={structuredRef} sessionId={sessionId} focus={focus} onClosed={onClosed} className={className} />
) : (
<TerminalPane ref={terminalRef} sessionId={sessionId} focus={focus} onClosed={onClosed} className={className} />
);
});
@@ -0,0 +1,48 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { Session } from "../../types.js";
import { PaneTitle } from "./PaneTitle.js";
afterEach(cleanup);
function session(over: Partial<Session> = {}): Session {
return {
id: "s1",
cwd: "/home/n",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...over,
} as Session;
}
describe("PaneTitle", () => {
it("falls back to a short session id when the session isn't known yet", () => {
render(<PaneTitle sessionId="abcdef12-3456-7890-abcd-ef1234567890" />);
expect(screen.getByText(/abcdef12/)).toBeTruthy();
});
it("shows the name alone when the command matches it", () => {
render(<PaneTitle sessionId="s1" session={session({ command: "claude", metadata: { name: "claude" } })} />);
expect(screen.getByText("claude")).toBeTruthy();
expect(document.querySelector(".at-cmd")).toBeNull();
});
it("shows the command alongside a custom name", () => {
render(
<PaneTitle sessionId="s1" session={session({ command: "hx src/main.rs", metadata: { name: "editor" } })} />,
);
expect(screen.getByText("editor")).toBeTruthy();
expect(document.querySelector(".at-cmd")).not.toBeNull();
});
it("never injects markup from a session name", () => {
render(<PaneTitle sessionId="s1" session={session({ metadata: { name: "<img onerror=x>" } })} />);
expect(document.querySelector("img")).toBeNull();
expect(screen.getByText("<img onerror=x>")).toBeTruthy();
});
});
+39
View File
@@ -0,0 +1,39 @@
// React port of paneTitle.ts: a pane header's session identity — the name,
// then the command as its tool mark when recognised, same as the sidebar
// card's own CommandFragment (SessionCard.tsx). JSX escapes text nodes, so
// unlike the vanilla string-builder this needs no manual HTML escaping.
import { splitCommand, toolIconSvg } from "../../toolIcon.js";
import { sessionName, type Session, type Uuid } from "../../types.js";
export interface PaneTitleProps {
sessionId: Uuid;
session?: Session;
className?: string;
}
export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
if (!session) {
return (
<span className={className ?? "at-title"} title={sessionId}>
session <span className="sid">{sessionId.slice(0, 8)}</span>
</span>
);
}
const name = sessionName(session);
const showCommand = Boolean(session.command) && session.command !== name;
const { tool, program } = splitCommand(session.command);
const icon = tool ? toolIconSvg(tool) : null;
return (
<span className={className ?? "at-title"} title={`${name} · ${session.command} (${sessionId})`}>
<span className="at-name">{name}</span>
{showCommand && (
<span className="at-cmd">
{icon ? <span className="contents" dangerouslySetInnerHTML={{ __html: icon }} /> : <span>{program}</span>}
</span>
)}
</span>
);
}
+88
View File
@@ -0,0 +1,88 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createLayoutStore } from "../../stores/layoutStore.js";
import { TabBar } from "./TabBar.js";
afterEach(cleanup);
function store() {
return createLayoutStore({
load: () => null,
save: vi.fn(),
scheduler: { setTimer: () => 1, clearTimer: () => {} },
});
}
function dataTransfer() {
const data = new Map<string, string>();
return {
setData: (type: string, value: string) => data.set(type, value),
getData: (type: string) => data.get(type) ?? "",
effectAllowed: "",
};
}
describe("TabBar", () => {
it("renders one tab per layout tab, marking the active one", () => {
const s = store();
s.getState().addTab();
render(<TabBar store={s} />);
const tabs = screen.getAllByRole("tab");
expect(tabs).toHaveLength(2);
expect(tabs[1].getAttribute("aria-selected")).toBe("true");
expect(tabs[0].getAttribute("aria-selected")).toBe("false");
});
it("shows the open-session count next to a tab's title", () => {
const s = store();
s.getState().openSession("s1");
render(<TabBar store={s} />);
expect(screen.getByText(/· 1/)).toBeTruthy();
});
it("clicking a tab selects it", () => {
const s = store();
s.getState().addTab();
const [first] = s.getState().layout.tabs;
render(<TabBar store={s} />);
fireEvent.click(screen.getAllByRole("tab")[0]);
expect(s.getState().layout.activeTabId).toBe(first.id);
});
it("the + button adds a tab", () => {
const s = store();
render(<TabBar store={s} />);
fireEvent.click(screen.getByTitle("New tab"));
expect(s.getState().layout.tabs).toHaveLength(2);
});
it("hides the close control on the last remaining tab", () => {
const s = store();
render(<TabBar store={s} />);
expect(screen.queryByTitle("Close tab")).toBeNull();
});
it("closing a tab removes it", () => {
const s = store();
s.getState().addTab();
render(<TabBar store={s} />);
fireEvent.click(screen.getAllByTitle("Close tab")[0]);
expect(s.getState().layout.tabs).toHaveLength(1);
});
it("dragging a tab onto another reorders them", () => {
const s = store();
s.getState().addTab();
s.getState().addTab();
const [a, b, c] = s.getState().layout.tabs;
render(<TabBar store={s} />);
const tabs = screen.getAllByRole("tab");
const dt = dataTransfer();
fireEvent.dragStart(tabs[0], { dataTransfer: dt });
fireEvent.dragOver(tabs[2], { dataTransfer: dt });
fireEvent.drop(tabs[2], { dataTransfer: dt });
expect(s.getState().layout.tabs.map((t) => t.id)).toEqual([b.id, c.id, a.id]);
});
});
+102
View File
@@ -0,0 +1,102 @@
// The tab strip above the canvas (issue #249): click to switch, drag to
// reorder, a close ✕ per tab (hidden on the last one), a trailing + to add.
import { useState } from "react";
import { useStore } from "zustand";
import { leaves } from "../../layout.js";
import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
export interface TabBarProps {
store: LayoutStoreApi;
}
export function TabBar({ store }: TabBarProps) {
const layout = useStore(store, (s: LayoutStore) => s.layout);
const previewTempTabId = useStore(store, (s: LayoutStore) => s.preview?.tempTabId ?? null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
return (
<div className="mv-tabs flex items-center gap-sp-1 overflow-x-auto border-b border-border-strong bg-surface px-sp-2 text-fs-12" role="tablist">
{layout.tabs.map((tab) => {
const count = leaves(tab.root).filter((l) => l.sessionId).length;
const isActive = tab.id === layout.activeTabId;
const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id;
// A preview's throwaway tab is not a place to go — it goes on its
// own the moment the preview ends (docs/PREVIEW.md).
const isPreviewTab = tab.id === previewTempTabId;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={isActive}
draggable
data-tab-id={tab.id}
className={
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
(isPreviewTab
? "border border-dashed border-preview-ring italic text-preview"
: isActive
? "bg-surface-2 text-ink"
: "text-faint hover:text-ink") +
(isDropTarget ? " outline outline-2 outline-accent" : "")
}
onDragStart={(e) => {
setDraggingId(tab.id);
e.dataTransfer.setData("text/plain", tab.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!draggingId || draggingId === tab.id) return;
e.preventDefault();
setOverId(tab.id);
}}
onDragLeave={() => setOverId((current) => (current === tab.id ? null : current))}
onDrop={(e) => {
e.preventDefault();
const fromId = e.dataTransfer.getData("text/plain") || draggingId;
setDraggingId(null);
setOverId(null);
if (fromId && fromId !== tab.id) store.getState().reorderTab(fromId, tab.id);
}}
onDragEnd={() => {
setDraggingId(null);
setOverId(null);
}}
onClick={() => {
if (isPreviewTab) return;
store.getState().selectTab(tab.id);
}}
>
<span>
{tab.title}
{count ? ` · ${count}` : ""}
</span>
{layout.tabs.length > 1 && !isPreviewTab && (
<span
className="mv-tab-close text-faint hover:text-ink"
title="Close tab"
onClick={(e) => {
e.stopPropagation();
store.getState().closeTab(tab.id);
}}
>
✕
</span>
)}
</button>
);
})}
<button
type="button"
className="mv-tab-add px-sp-2 py-sp-1 text-muted hover:text-ink"
title="New tab"
onClick={() => store.getState().addTab()}
>
+
</button>
</div>
);
}
@@ -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,
);
}
+6 -6
View File
@@ -62,7 +62,7 @@ export function SessionCard(props: SessionCardProps) {
return (
<a
className={`session-card tree-leaf relative flex flex-col gap-[1px] py-sp-1 pr-sp-2 no-underline ${rowFill(props)} ${
className={`session-card tree-leaf no-underline ${rowFill(props)} ${
running ? "" : "opacity-60"
}`}
href={`#/attach/${encodeURIComponent(s.id)}`}
@@ -84,7 +84,7 @@ export function SessionCard(props: SessionCardProps) {
}}
>
<ActivityRail activity={s.activity} />
<span className="flex min-w-0 items-center gap-sp-2">
<span className="card-line1">
{renaming ? (
<RenameField
session={s}
@@ -93,17 +93,17 @@ export function SessionCard(props: SessionCardProps) {
/>
) : (
<span
className={`min-w-0 flex-1 truncate text-fs-13 ${
className={`leaf-name min-w-0 flex-1 truncate text-fs-13 ${
isProvisionalName(s) ? "text-muted" : "text-ink"
}`}
>
{name}
</span>
)}
<span className="flex flex-none items-center gap-[2px]">
<span className="leaf-actions">
<button
type="button"
className="leaf-clone text-faint hover:text-ink"
className="leaf-act leaf-clone"
title="Spawn similar"
aria-label={`Spawn similar to ${name}`}
onClick={(e) => {
@@ -116,7 +116,7 @@ export function SessionCard(props: SessionCardProps) {
</button>
<button
type="button"
className="leaf-kill text-faint hover:text-danger"
className="leaf-act leaf-kill"
title="Kill session"
aria-label={`Kill ${name}`}
onClick={(e) => {
+45 -31
View File
@@ -2,9 +2,15 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "../../contexts/ThemeContext.js";
import type { Session } from "../../types.js";
import { Sidebar } from "./Sidebar.js";
/** Wrap children in ThemeProvider so useTheme works in the footer. */
function Wrapper({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>;
}
afterEach(() => {
cleanup();
vi.useRealTimers();
@@ -36,7 +42,7 @@ function tree(): HTMLElement {
describe("SessionCard layout", () => {
it("renders two lines: the name, then a meta line naming the program", () => {
render(<Sidebar sessions={[session("alpha")]} />);
render(<Wrapper><Sidebar sessions={[session("alpha")]} /></Wrapper>);
const row = card("alpha");
expect(row.textContent).toContain("alpha");
@@ -52,7 +58,7 @@ describe("SessionCard layout", () => {
const fresh = session("fresh", { activity: "Active", activity_since: "2026-08-24T09:59:50Z" });
const held = session("held", { activity: "Active", activity_since: "2026-08-24T09:56:00Z" });
render(<Sidebar sessions={[fresh, held]} />);
render(<Wrapper><Sidebar sessions={[fresh, held]} /></Wrapper>);
expect(card("fresh").querySelector(".activity-duration")).toBeNull();
expect(card("held").querySelector(".activity-duration")!.textContent).toBe("4m");
@@ -60,13 +66,15 @@ describe("SessionCard layout", () => {
it("paints a rail for every state, blinking on AwaitingInput and flashing on Error", () => {
render(
<Sidebar
sessions={[
session("idle"),
session("await", { activity: "AwaitingInput" }),
session("err", { activity: "Error" }),
]}
/>,
<Wrapper>
<Sidebar
sessions={[
session("idle"),
session("await", { activity: "AwaitingInput" }),
session("err", { activity: "Error" }),
]}
/>
</Wrapper>,
);
const rail = (id: string) => card(id).querySelector<HTMLElement>("[data-activity]")!;
@@ -80,13 +88,15 @@ describe("SessionCard layout", () => {
it("renders blocked work as diff notation with the spelled-out tooltip", () => {
render(
<Sidebar
sessions={[
session("alpha", {
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
}),
]}
/>,
<Wrapper>
<Sidebar
sessions={[
session("alpha", {
blocked_work: { uncommitted_files: 2, insertions: 124, deletions: 18, untracked_files: 3 },
}),
]}
/>
</Wrapper>,
);
const blocked = card("alpha").querySelector<HTMLElement>(".leaf-blocked")!;
@@ -96,12 +106,14 @@ describe("SessionCard layout", () => {
it("dims a provisional name and leaves a settled one alone", () => {
render(
<Sidebar
sessions={[
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
session("user", { metadata: { name: "user", name_source: "User" } }),
]}
/>,
<Wrapper>
<Sidebar
sessions={[
session("gen", { metadata: { name: "gen", name_source: "Generated" } }),
session("user", { metadata: { name: "user", name_source: "User" } }),
]}
/>
</Wrapper>,
);
expect(card("gen").querySelector(".text-muted")).not.toBeNull();
@@ -113,7 +125,7 @@ describe("keyboard walk", () => {
const two = [session("a", { cwd: "/one" }), session("b", { cwd: "/two" })];
it("j and k step the highlight through the cards", () => {
render(<Sidebar sessions={two} />);
render(<Wrapper><Sidebar sessions={two} /></Wrapper>);
fireEvent.keyDown(tree(), { key: "j" });
expect(card("a").dataset.highlighted).toBe("true");
@@ -124,7 +136,7 @@ describe("keyboard walk", () => {
});
it("stops on a collapsed folder header and Enter expands onto its first card", () => {
render(<Sidebar sessions={two} />);
render(<Wrapper><Sidebar sessions={two} /></Wrapper>);
const header = document.querySelector<HTMLElement>('[data-nav-key="folder:one"]')!;
fireEvent.click(header);
@@ -142,7 +154,7 @@ describe("keyboard walk", () => {
it("Enter on a card opens it, and Tab hands the keyboard to the pane", () => {
const onSelect = vi.fn();
const onFocusPane = vi.fn();
render(<Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} />);
render(<Wrapper><Sidebar sessions={two} onSelect={onSelect} onFocusPane={onFocusPane} /></Wrapper>);
fireEvent.keyDown(tree(), { key: "j" });
fireEvent.keyDown(tree(), { key: "Enter" });
@@ -154,7 +166,7 @@ describe("keyboard walk", () => {
it("previews the session the walk lands on", () => {
const onPreview = vi.fn();
render(<Sidebar sessions={two} onPreview={onPreview} />);
render(<Wrapper><Sidebar sessions={two} onPreview={onPreview} /></Wrapper>);
fireEvent.keyDown(tree(), { key: "j" });
expect(onPreview).toHaveBeenCalledWith("a");
@@ -162,7 +174,7 @@ describe("keyboard walk", () => {
it("x asks to kill the highlighted session and r renames it", () => {
const onKill = vi.fn();
render(<Sidebar sessions={two} onKill={onKill} />);
render(<Wrapper><Sidebar sessions={two} onKill={onKill} /></Wrapper>);
fireEvent.keyDown(tree(), { key: "j" });
fireEvent.keyDown(tree(), { key: "x" });
@@ -178,7 +190,9 @@ describe("context menu", () => {
const onSpawnSimilar = vi.fn();
const onKill = vi.fn();
render(
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />,
<Wrapper>
<Sidebar sessions={[session("alpha")]} onSpawnSimilar={onSpawnSimilar} onKill={onKill} />
</Wrapper>,
);
fireEvent.contextMenu(card("alpha"));
@@ -191,7 +205,7 @@ describe("context menu", () => {
it("renames in place: Enter commits the field, Escape cancels", () => {
const onRename = vi.fn();
render(<Sidebar sessions={[session("alpha")]} onRename={onRename} />);
render(<Wrapper><Sidebar sessions={[session("alpha")]} onRename={onRename} /></Wrapper>);
fireEvent.contextMenu(card("alpha"));
fireEvent.click(screen.getByText("Rename…"));
@@ -207,7 +221,7 @@ describe("context menu", () => {
describe("search", () => {
it("filters the tree down to matching sessions", () => {
render(<Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} />);
render(<Wrapper><Sidebar sessions={[session("alpha"), session("beta", { cwd: "/home/n/beta" })]} /></Wrapper>);
fireEvent.change(screen.getByLabelText("Search sessions"), { target: { value: "beta" } });
@@ -219,7 +233,7 @@ describe("search", () => {
describe("empty state", () => {
it("offers a spawn when there are no sessions at all", () => {
const onSpawn = vi.fn();
render(<Sidebar sessions={[]} onSpawn={onSpawn} />);
render(<Wrapper><Sidebar sessions={[]} onSpawn={onSpawn} /></Wrapper>);
fireEvent.click(screen.getByText("+ Spawn session"));
expect(onSpawn).toHaveBeenCalled();
+127 -7
View File
@@ -10,9 +10,13 @@ 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, requestToggleHelp } from "../../uiEvents.js";
import { ContextMenu, type ContextMenuItem } from "../common/ContextMenu.js";
import type { DaemonLinkStoreApi, DaemonLinkState } from "../daemon/DaemonLink.js";
import { formatRelease } from "../daemon/DaemonLink.js";
import { useTheme, PALETTES, type Palette } from "../../contexts/ThemeContext.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";
@@ -42,6 +46,8 @@ export interface SidebarProps {
/** Tab: hand the keyboard back to the pane. */
onFocusPane?(): void;
onHelp?(): void;
/** The daemon-link store, for the status indicator in the footer. */
linkStore?: DaemonLinkStoreApi;
/** Overrides the built-in `DELETE /api/sessions/:id` flow (tests, kit). */
onKill?(session: Session): void;
/** Overrides the built-in `PATCH /api/sessions/:id` (tests, kit). */
@@ -58,6 +64,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 +166,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 +325,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,12 +345,19 @@ export function Sidebar(props: SidebarProps) {
session: s,
blockers: e.unsavedWork ?? sessionBlockers(s),
force: true,
reason: e.message,
});
return;
}
});
throw e;
}
}}
/>
)}
<SidebarFooter
onSpawn={() => props.onSpawn?.()}
linkStore={props.linkStore}
/>
</aside>
);
}
@@ -366,3 +405,84 @@ const EMPTY_STORE = {
setState: () => {},
subscribe: () => () => {},
} as unknown as CatalogStoreApi;
// ---------------------------------------------------------------------------
// Sidebar footer: help, theme toggle, palette picker, status dot, spawn.
// Mirrors the vanilla catalog.ts footer that the React migration dropped.
// ---------------------------------------------------------------------------
function SidebarFooter({
onSpawn,
linkStore,
}: {
onSpawn(): void;
linkStore?: DaemonLinkStoreApi;
}) {
const { theme, toggleTheme, palette, setPalette } = useTheme();
return (
<div className="sidebar-footer">
<button
type="button"
className="btn btn-ghost btn-icon help-btn"
title="Keyboard shortcuts (?)"
aria-label="Keyboard shortcuts"
onClick={requestToggleHelp}
>
?
</button>
<button
type="button"
className="btn btn-ghost btn-icon"
title={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
onClick={toggleTheme}
>
{theme === "dark" ? "☀" : "☾"}
</button>
<select
className="palette-select"
value={palette}
aria-label="Colour palette"
onChange={(e) => setPalette(e.currentTarget.value as Palette)}
>
{PALETTES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
{linkStore && <StatusDot store={linkStore} />}
<button
type="button"
className="btn btn-primary spawn-btn"
title="Spawn form: n — fast-forward spawn: N"
onClick={onSpawn}
>
+ Spawn
</button>
</div>
);
}
/** Daemon status indicator: a coloured dot whose tooltip names the release. */
function StatusDot({ store }: { store: DaemonLinkStoreApi }) {
const state = useStore(store, (s: DaemonLinkState) => s.state);
const build = useStore(store, (s: DaemonLinkState) => s.build);
const hostname = useStore(store, (s: DaemonLinkState) => s.hostname);
return (
<button
type="button"
className="status-chip btn btn-ghost btn-icon"
data-state={state}
aria-label={`Daemon ${state}`}
>
<span className="status-dot" />
<span className="status-tip">
<span>{hostname ?? "maestro"}</span>
<span className="text-faint">{formatRelease(build)}</span>
</span>
</button>
);
}
+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;
+16 -12
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";
@@ -104,9 +104,11 @@ export interface SpawnFormProps {
/** Fired whenever any value changes, once the form's initial seed settles. */
onChange?(values: SpawnFormValues): void;
onSuccess(session: Session): void;
/** When true, hides the built-in submit button (the modal provides its own). */
externalSubmit?: boolean;
}
export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
export function SpawnForm({ seed, onChange, onSuccess, externalSubmit }: SpawnFormProps) {
const prefill = useRef(seed ?? {}).current;
const formRef = useRef<HTMLFormElement>(null);
@@ -240,7 +242,7 @@ export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [, setSubmitting] = useState(false);
const [ready, setReady] = useState(false);
const probeTokenRef = useRef(0);
@@ -620,6 +622,7 @@ export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
return (
<form
id="spawn-form"
ref={formRef}
onSubmit={onSubmit}
onKeyDown={onFormKeyDown}
@@ -839,15 +842,16 @@ export function SpawnForm({ seed, onChange, onSuccess }: SpawnFormProps) {
<p role="alert" aria-live="polite" className={`m-0 text-fs-13 text-danger ${error ? "rounded-r-2 bg-danger-tint px-sp-3 py-sp-2" : "min-h-[1.2em]"}`}>
{error}
</p>
<div className="flex justify-end">
<button
type="submit"
disabled={submitting}
className="rounded-r-1 bg-accent px-sp-4 py-sp-2 text-fs-13 font-semibold text-accent-ink disabled:opacity-60"
>
Spawn
</button>
</div>
{!externalSubmit && (
<div className="flex justify-end">
<button
type="submit"
className="btn btn-primary rounded-r-1 px-sp-4 py-sp-1 text-fs-13 font-semibold"
>
Spawn
</button>
</div>
)}
</form>
);
}
+9 -2
View File
@@ -64,16 +64,23 @@ export function SpawnModal({ seed, onSuccess, onClose }: SpawnModalProps) {
</button>
</header>
<div ref={filterInputRef} className="min-h-0 flex-1 overflow-auto">
<SpawnForm seed={seed} onSuccess={handleSuccess} />
<SpawnForm seed={seed} onSuccess={handleSuccess} externalSubmit />
</div>
<div className="flex justify-end gap-sp-2 border-t border-border p-sp-3">
<button
type="button"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
className="rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-13 text-ink"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
form="spawn-form"
className="btn btn-primary rounded-r-1 px-sp-4 py-sp-1 text-fs-13 font-semibold"
>
Spawn
</button>
</div>
</div>
</div>,
+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";
@@ -175,7 +175,7 @@ describe("tool rows and activity groups", () => {
chunk("agent:2", "agent_text", "the answer"),
]);
const head = screen.getByText("2 tool calls").closest("button")!;
const head = screen.getByText(/^2 tool calls/).closest("button")!;
expect(head.getAttribute("aria-expanded")).toBe("false");
// The final answer stays outside the group; the intermediate text is in it.
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
@@ -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,
@@ -354,6 +354,8 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
key={item.id}
id={item.id}
toolCount={item.group.toolCount}
lastToolLabel={item.group.lastToolLabel}
lastToolArg={item.group.lastToolArg}
expanded={expandedGroups.has(item.id)}
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
>
+12 -4
View File
@@ -1,19 +1,27 @@
// A turn's collapsed activity block: two or more tool rows after the same
// `User` row, plus the narration absorbed between them. The header counts tool
// rows only (docs/STRUCTURED-VIEW.md); opening it reveals tools and text in
// chronological order.
// rows only (docs/STRUCTURED-VIEW.md) and shows the last/current tool label so
// the operator sees activity at a glance without expanding. Opening it reveals
// tools and text in chronological order.
import type { ReactNode } from "react";
export interface ToolGroupProps {
id: string;
toolCount: number;
/** Label of the last (or running) tool, e.g. "Execute". */
lastToolLabel: string;
/** Argument of the last tool, e.g. "`git status`". */
lastToolArg: string;
expanded: boolean;
onExpandedChange(expanded: boolean): void;
children: ReactNode;
}
export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children }: ToolGroupProps) {
export function ToolGroup({ id, toolCount, lastToolLabel, lastToolArg, expanded, onExpandedChange, children }: ToolGroupProps) {
const summary = lastToolArg
? `${toolCount} tool calls · ${lastToolLabel} \u2018${lastToolArg}\u2019`
: `${toolCount} tool calls`;
return (
<div className="st-tool-group" data-tool-group-id={id}>
<button
@@ -25,7 +33,7 @@ export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children
<span className="st-tool-group-glyph" aria-hidden="true">
›
</span>
<span className="st-tool-group-title">{`${toolCount} tool calls`}</span>
<span className="st-tool-group-title">{summary}</span>
</button>
<div
className={
@@ -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]);
@@ -151,6 +151,33 @@ describe("activity grouping", () => {
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
});
it("keeps the newest agent_text outside while the turn is still live", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
text("agent:1", "agent_text", "working on it"),
tool("tool:2"),
text("agent:2", "agent_text", "almost done"),
];
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
// "almost done" stays outside as visible narration while the turn is live
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
// Once ended, the final agent_text stays outside as the answer
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
});
it("tracks the last tool's label and argument for the collapsed header", () => {
const rows = [
text("user:1", "user", "go"),
row("tool:1", "tool_call", { title: "Read(src/main.ts)", status: "completed" }),
row("tool:2", "tool_call", { title: "Execute(git status)", kind: "execute", status: "completed" }),
];
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
expect(group.lastToolLabel).toBe("Execute");
expect(group.lastToolArg).toBe("git status");
});
it("absorbs a resolved approval, never the pending one", () => {
const rows = [
text("user:1", "user", "go"),
+31 -11
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. */
@@ -148,6 +148,9 @@ export interface ActivityGroup {
toolCount: number;
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
rows: TranscriptRow[];
/** `splitToolTitle` of the last tool row — the collapsed header shows it. */
lastToolLabel: string;
lastToolArg: string;
}
/**
@@ -158,6 +161,10 @@ export interface ActivityGroup {
* the currently pending one) are absorbed the same way. The *last* agent text
* with no subsequent tool stays standalone (the prominent final answer).
*
* While a turn is still live, the most recent text or thought row trailing the
* group also stays outside as visible narration — the operator always sees the
* agent's latest reasoning without opening the group.
*
* Returns groups keyed by `turn:<userId>`.
*/
export function activityGroupsByTurn(
@@ -197,23 +204,36 @@ export function activityGroupsByTurn(
continue;
}
if (row.kind === "tool_call" || row.kind === "tool_diff") {
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
const group = groups.get(currentGid) ?? { toolCount: count, rows: [], lastToolLabel: "", lastToolArg: "" };
// Flush any pending rows — a tool follows, so they're intermediate.
group.rows.push(...pending);
pending = [];
group.rows.push(row);
const tool = (row.data ?? {}) as { title?: string; name?: string | null; kind?: string };
const { label, arg } = splitToolTitle(tool.title, tool.name ?? undefined, tool.kind);
group.lastToolLabel = label;
group.lastToolArg = arg;
groups.set(currentGid, group);
}
}
// A final agent text is the answer, so it stays outside. Thought rows are
// working commentary, though: when a turn already has a disclosure they
// belong with its activity even if the next tool call has not arrived yet.
// While the turn is still live the most recent one stays outside as visible
// narration (docs/STRUCTURED-VIEW.md), and is absorbed when the turn ends.
// Trailing text/thought rows after the last tool in this turn.
// While the turn is still live, the newest one stays outside as visible
// narration — the operator always sees the agent's latest reasoning.
// A final agent_text (the "answer") stays outside regardless.
const lastGroup = groups.get(currentGid);
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
const absorb = turnActive ? pending.slice(0, -1) : pending;
lastGroup.rows.push(...absorb);
if (lastGroup && pending.length > 0) {
const allThoughts = pending.every((row) => row.kind === "agent_thought");
if (allThoughts) {
// Pure thoughts: absorb all when done, keep the newest outside while live.
const absorb = turnActive ? pending.slice(0, -1) : pending;
lastGroup.rows.push(...absorb);
} else if (turnActive) {
// Mixed or agent_text trailing: keep the very last row outside while live,
// absorb the rest.
lastGroup.rows.push(...pending.slice(0, -1));
}
// When the turn ends with agent_text last, nothing extra is absorbed — the
// text stays outside as the final answer.
}
return groups;
+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`;
}
+4 -1
View File
@@ -1093,7 +1093,9 @@
position: relative;
width: 40px;
padding: 0;
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
border-color: transparent;
background: transparent;
box-shadow: none;
color: var(--danger);
}
@@ -1287,6 +1289,7 @@
border-color: transparent;
background: transparent;
color: var(--muted);
align-self: center;
}
.structured-attach:hover {
+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}
-383
View File
@@ -1,383 +0,0 @@
// @vitest-environment happy-dom
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;
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(hidden.querySelector(`[data-session="${B}"]`)).not.toBeNull();
expect((hidden.querySelector(`[data-session="${A}"]`) as HTMLElement | null)?.dataset.kind).toBe("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);
(pane.firstElementChild as HTMLElement).focus();
expect(document.activeElement).toBe(pane.firstElementChild);
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);
(pane.firstElementChild as HTMLElement).focus();
const sidebarInput = document.createElement("input");
document.body.appendChild(sidebarInput);
sidebarInput.focus();
expect(document.activeElement).toBe(sidebarInput);
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");
(pane.firstElementChild as HTMLElement).appendChild(textarea);
textarea.focus();
expect(document.activeElement).toBe(textarea);
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.activeElement).toBe(document.body);
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 { closeContextMenu, openContextMenu } from "./contextMenu.js";
afterEach(() => {
closeContextMenu();
document.body.replaceChildren();
});
function labels(): string[] {
return [...document.querySelectorAll(".context-menu-item")].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(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe(
"Kill session",
);
});
it("runs the selected entry and closes", () => {
const chosen = vi.fn();
openContextMenu(0, 0, [{ label: "Rename…", onSelect: chosen }]);
(document.querySelector(".context-menu-item") as HTMLButtonElement).click();
expect(chosen).toHaveBeenCalledOnce();
expect(document.querySelector(".context-menu")).toBeNull();
});
it("opening a second menu replaces the first", () => {
openContextMenu(0, 0, [{ label: "first", onSelect: () => {} }]);
openContextMenu(0, 0, [{ label: "second", onSelect: () => {} }]);
expect(document.querySelectorAll(".context-menu")).toHaveLength(1);
expect(labels()).toEqual(["second"]);
});
it("closes on Escape and on a press outside it", () => {
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(document.querySelector(".context-menu")).toBeNull();
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(document.querySelector(".context-menu")).toBeNull();
});
it("stays open for a press inside itself", () => {
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
const menu = document.querySelector(".context-menu") as HTMLElement;
menu.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(document.querySelector(".context-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;
}
+16 -8
View File
@@ -26,15 +26,23 @@ export type Palette = (typeof PALETTES)[number];
const PALETTE_KEY = "maestro-palette";
function storedTheme(): Theme | null {
const v = localStorage.getItem(THEME_KEY);
return v === "light" || v === "dark" ? v : null;
try {
const v = localStorage.getItem(THEME_KEY);
return v === "light" || v === "dark" ? v : null;
} catch {
return null;
}
}
function storedPalette(): Palette | null {
const v = localStorage.getItem(PALETTE_KEY);
return (PALETTES as readonly string[]).includes(v ?? "")
? (v as Palette)
: null;
try {
const v = localStorage.getItem(PALETTE_KEY);
return (PALETTES as readonly string[]).includes(v ?? "")
? (v as Palette)
: null;
} catch {
return null;
}
}
function systemPrefersLight(): boolean {
@@ -89,7 +97,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}, [palette]);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem(THEME_KEY, t);
try { localStorage.setItem(THEME_KEY, t); } catch { /* test env */ }
setPinnedTheme(t);
}, []);
@@ -98,7 +106,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}, [theme, setTheme]);
const setPalette = useCallback((p: Palette) => {
localStorage.setItem(PALETTE_KEY, p);
try { localStorage.setItem(PALETTE_KEY, p); } catch { /* test env */ }
setPaletteState(p);
}, []);
-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));
}
-143
View File
@@ -1,143 +0,0 @@
// @vitest-environment happy-dom
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;
function popup(): HTMLUListElement {
return document.querySelector(".dir-ac") as HTMLUListElement;
}
function items(): HTMLElement[] {
return [...popup().querySelectorAll<HTMLElement>(".dir-ac-item")];
}
async function type(value: string): Promise<void> {
input.focus();
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
await vi.advanceTimersByTimeAsync(100); // past the fetch debounce
}
/** Dispatch 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 evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
input.dispatchEvent(evt);
return { defaultPrevented: evt.defaultPrevented };
}
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().hidden).toBe(false);
const { defaultPrevented } = press("Escape");
expect(defaultPrevented).toBe(true);
expect(popup().hidden).toBe(true);
expect(input.value).toBe("/tmp/pro");
expect(document.activeElement).toBe(input);
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().hidden).toBe(false);
input.blur();
await vi.advanceTimersByTimeAsync(150);
expect(popup().hidden).toBe(true);
});
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().hidden).toBe(true);
await type("/tmp/ot");
expect(popup().hidden).toBe(false);
});
});
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(document.activeElement).toBe(nextField); // 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().hidden).toBe(true);
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].classList.contains("is-active")).toBe(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().hidden).toBe(false);
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();
},
};
}
-47
View File
@@ -1,47 +0,0 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
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();
(sidebar.querySelector(".tree-leaf") as HTMLElement).click();
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when a spawn is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when spawn similar is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("leaves the backdrop click closing it", () => {
const { shell } = mount();
(shell.lastElementChild as HTMLElement).click();
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 { 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 {
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
}
function rows(): string[] {
return [...document.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 groups = groupedCommands();
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
groups.map((g) => g.title),
);
expect(rows()).toEqual(
groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))),
);
expect([...document.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();
expect([...document.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 keysOf = (id: string) =>
[
...(document.querySelector(`.help-row[data-command-id="${id}"]`) as HTMLElement)
.querySelectorAll("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();
(document.querySelector(".help-close") as HTMLButtonElement).click();
expect(isHelpOpen()).toBe(false);
openHelp();
(document.querySelector(".help-overlay") as HTMLElement).dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
expect(isHelpOpen()).toBe(false);
});
it("stays open for a click inside the panel", () => {
openHelp();
(document.querySelector(".help-panel") as HTMLElement).dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
expect(isHelpOpen()).toBe(true);
});
it("opening twice leaves one overlay", () => {
openHelp();
openHelp();
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
});
it("ignores ? while typing and when a non-shift modifier is held", () => {
const input = document.createElement("input");
document.body.appendChild(input);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "?", bubbles: true, 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(document.querySelectorAll(".help-overlay")).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 };
}
+174
View File
@@ -0,0 +1,174 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { leaves } from "../layout.js";
import { createLayoutStore, type LayoutStoreApi } from "../stores/layoutStore.js";
import { usePreview, type UsePreviewResult } from "./usePreview.js";
function fakeScheduler() {
const timers = new Map<number, () => void>();
let id = 0;
return {
scheduler: {
setTimer: (fn: () => void) => {
const h = ++id;
timers.set(h, fn);
return h;
},
clearTimer: (h: number) => {
timers.delete(h);
},
},
flush() {
for (const fn of [...timers.values()]) fn();
timers.clear();
},
};
}
/** Hand-driven clock + timer queue for the preview schedule's own throttle,
* independent of the store's save-debounce scheduler above. */
function fakeClock() {
let now = 1_000;
const timers = new Map<number, { at: number; fn: () => void }>();
let handle = 1;
return {
now: () => now,
setTimer: (fn: () => void, ms: number) => {
const h = handle++;
timers.set(h, { at: now + ms, fn });
return h;
},
clearTimer: (h: number) => {
timers.delete(h);
},
advance(ms: number) {
now += ms;
for (const [h, t] of [...timers]) {
if (t.at <= now) {
timers.delete(h);
act(() => t.fn());
}
}
},
};
}
function newStore(): LayoutStoreApi {
return createLayoutStore({ load: () => null, save: vi.fn(), scheduler: fakeScheduler().scheduler });
}
let controls: UsePreviewResult;
function Probe({ store, clock }: { store: LayoutStoreApi; clock: ReturnType<typeof fakeClock> }) {
controls = usePreview({
store,
scheduleDeps: { now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer },
});
return <div>{controls.previewedSessionId ?? "none"}</div>;
}
afterEach(cleanup);
describe("usePreview", () => {
it("shows the first preview immediately — no settling delay", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => controls.preview("s9"));
expect(view.container.textContent).toBe("s9");
expect(leaves(store.getState().layout.tabs[1]?.root ?? null).map((l) => l.sessionId)).toEqual(["s9"]);
});
it("throttles a burst to one leading + one trailing pane swap", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => {
controls.preview("s1");
controls.preview("s8");
controls.preview("s9");
});
expect(view.container.textContent).toBe("s1");
clock.advance(120);
expect(view.container.textContent).toBe("s9");
});
it("touch never previews", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => controls.previewFromPointer("s9", "touch"));
expect(view.container.textContent).toBe("none");
expect(store.getState().preview).toBeNull();
});
it("previewFromPointer still previews for mouse/pen", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => controls.previewFromPointer("s9", "mouse"));
expect(view.container.textContent).toBe("s9");
});
it("commit cancels a pending trailing preview and opens for real", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => {
controls.preview("s1"); // leading edge: previews the already-open s1
controls.preview("s9"); // queued, gap not elapsed yet
controls.commit("s9");
});
expect(view.container.textContent).toBe("none"); // committed: no longer a preview
expect(leaves(store.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s9"]);
clock.advance(120);
// The cancelled trailing preview never fires.
expect(view.container.textContent).toBe("none");
});
it("end drops a preview immediately, bypassing the throttle", () => {
const store = newStore();
store.getState().openSession("s1");
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => controls.preview("s9"));
expect(view.container.textContent).toBe("s9");
act(() => controls.end());
expect(view.container.textContent).toBe("none");
expect(store.getState().layout.tabs).toHaveLength(1);
});
it("cancels its pending trailing preview on unmount", () => {
const store = newStore();
store.getState().openSession("s1");
store.getState().addTab();
store.getState().openSession("s2"); // leading edge below will preview this, already on screen
const clock = fakeClock();
const view = render(<Probe store={store} clock={clock} />);
act(() => {
controls.preview("s2"); // leading edge: fires now
controls.preview("s9"); // queued trailing preview
});
expect(store.getState().preview?.sessionId).toBe("s2");
view.unmount();
clock.advance(120);
// The queued trailing preview must not fire after unmount.
expect(store.getState().preview?.sessionId).toBe("s2");
});
});
+81
View File
@@ -0,0 +1,81 @@
// React port of previewSchedule.ts + multiview.ts's preview controller
// (issue #251). Owns the throttled schedule, hands the layout store the
// throttled result, and exposes the three ways a preview ends for real —
// see docs/PREVIEW.md, the contract this mirrors.
import { useCallback, useEffect, useRef } from "react";
import { useStore } from "zustand";
import { createPreviewSchedule, type PreviewScheduleDeps } from "../previewSchedule.js";
import type { LayoutStore, LayoutStoreApi } from "../stores/layoutStore.js";
import type { Uuid } from "../types.js";
export interface UsePreviewOptions {
store: LayoutStoreApi;
/** Test seam for the schedule's clock/timers. */
scheduleDeps?: PreviewScheduleDeps;
}
export interface UsePreviewResult {
/** The session currently shown as a preview, or null. */
previewedSessionId: Uuid | null;
/** Throttled: show `sessionId` in the content pane, or `null` to put back
* whatever was on screen (a highlight move, or the highlight leaving
* every session). Never delayed on the leading edge. */
preview(sessionId: Uuid | null): void;
/** Pointer-hover entry point: touch has no hover, so a touch pointer never
* previews (docs/PREVIEW.md). */
previewFromPointer(sessionId: Uuid | null, pointerType: string): void;
/** Choosing the previewed session — click/Enter on its row, click into its
* pane, or handing it the keyboard (Tab). Keeps exactly what is on
* screen: a throwaway tab becomes real, a focus move inside an existing
* tab stands. Nothing moves a second time. */
commit(sessionId: Uuid): void;
/** Any other real change — opening the command bar, a dialog, navigating
* away — ends a pending or active preview immediately, bypassing the
* throttle. */
end(): void;
}
/** Preview state management (docs/PREVIEW.md): wraps the layout store's
* `previewSession`/`commitPreview` with the 120ms pane-swap throttle so the
* UI never has to schedule it by hand. */
export function usePreview({ store, scheduleDeps }: UsePreviewOptions): UsePreviewResult {
const previewedSessionId = useStore(store, (s: LayoutStore) => s.preview?.sessionId ?? null);
const scheduleRef = useRef<ReturnType<typeof createPreviewSchedule> | null>(null);
if (!scheduleRef.current) {
scheduleRef.current = createPreviewSchedule((id) => store.getState().previewSession(id), scheduleDeps);
}
useEffect(() => {
const schedule = scheduleRef.current;
return () => schedule?.cancel();
}, []);
const preview = useCallback((sessionId: Uuid | null) => {
scheduleRef.current?.(sessionId);
}, []);
const previewFromPointer = useCallback(
(sessionId: Uuid | null, pointerType: string) => {
if (pointerType === "touch") return;
preview(sessionId);
},
[preview],
);
const commit = useCallback(
(sessionId: Uuid) => {
scheduleRef.current?.cancel();
store.getState().openSession(sessionId);
},
[store],
);
const end = useCallback(() => {
scheduleRef.current?.cancel();
store.getState().previewSession(null);
}, [store]);
return { previewedSessionId, preview, previewFromPointer, commit, end };
}
-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 />);
}
-33
View File
@@ -1,33 +0,0 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import {
closeForceKillDialog,
openForceKillDialog,
openKillConfirmDialog,
} from "./killDialog.js";
describe("kill dialogs", () => {
beforeEach(() => {
closeForceKillDialog();
document.body.innerHTML = "";
});
it("focuses the kill button, so Enter confirms", () => {
const el = openKillConfirmDialog({
name: "alpha",
blockers: null,
onKill: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-kill"));
});
it("focuses the force button too", () => {
const el = openForceKillDialog({
name: "alpha",
blockers: null,
reason: "worktree is dirty.",
onForce: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-force"));
});
});
-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,
});
}

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