Compare commits

...
9 Commits
Author SHA1 Message Date
Miguel Palhas f443f625a8 Merge remote-tracking branch 'origin/main' into coda
ci / web (push) Successful in 30s
ci / flake (push) Successful in 2m48s
ci / rust (push) Successful in 6m3s
2026-09-03 13:21:53 +01:00
Miguel PalhasandClaude Opus 5 655b7b790e fix(maestro): let Escape reach the session in the TUI
Insert mode bound `esc` to LeaveInsert, so full-screen apps in a session
(vim, Claude Code) never saw Escape — the only way out of Insert is now
the leader, which rebinding still follows.

A key bound to `Noop` in Insert mode is also forwarded to the pty now.
`Noop` documents itself as the way to suppress a default binding, and in
Insert mode an unbound key belongs to the session, so this is the escape
hatch for taking any other default back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 11:32:46 +01:00
Miguel Palhas 71e0e8d0ce Merge branch 'main' of https://git.naps.pt/naps62/maestro into oboe
ci / web (push) Successful in 1m17s
ci / flake (push) Successful in 3m22s
ci / rust (push) Successful in 6m58s
2026-09-03 11:12:02 +01:00
Miguel Palhas 582a7ca888 Merge remote-tracking branch 'origin/main' into oboe
# Conflicts:
#	web/src/catalog.ts
2026-09-03 11:11:40 +01:00
Miguel Palhas d8a5d95db4 Merge remote-tracking branch 'origin/main' into quintet
ci / rust (push) Failing after 7s
ci / web (push) Successful in 32s
ci / flake (push) Successful in 2m2s
2026-09-03 11:08:32 +01:00
Miguel Palhas 51002fece8 docs(maestro): correct the tracker described in CLAUDE.md
Issues moved to Gitea; the file still named a Linear project and team.
Adds the label taxonomy and the milestone/blitz workflow while there.
2026-09-03 11:06:22 +01:00
Miguel Palhas 2319b4e47b fix(maestro): keep the keyboard in the list while previewing
Two mounts were taking focus out of the sidebar mid-walk: the render that
put the multi-view on screen before the preview applied, and the render
that restores the previous pane when a preview ends. Both now mount
without focusing, so j/k keeps walking. Also marks the preview with a
badge on the canvas and drops the close button from its tab.
2026-09-03 10:52:43 +01:00
Miguel Palhas 6635cff46e feat(maestro): preview the highlighted session in the TUI pane
The sidebar highlight now shows its session in the pane after a 120ms
debounce, attaching if nothing already is, without the tab taking it on
and without reporting focus to the daemon. Preview-only attaches stay
warm behind the current one, bounded at three.

docs/PREVIEW.md.
2026-09-03 10:46:45 +01:00
Miguel Palhas f6b2cf3ab4 feat(maestro): preview the highlighted session in the web pane
Moving the sidebar highlight (keyboard or hover) now shows that session in
the content pane without opening it: its own tab comes forward, or a
throwaway tab carries it. The layout, the saved layout and the daemon's
focus report are all left untouched, and the keyboard stays in the list.

Contract in docs/PREVIEW.md.
2026-09-03 10:42:53 +01:00
13 changed files with 1055 additions and 63 deletions
+13 -3
View File
@@ -36,10 +36,16 @@ optional.
## Where the work is
Linear project **Maestro** under team **Naps62**.
Gitea issues on [`naps62/maestro`](https://git.naps.pt/naps62/maestro), grouped
into milestones.
`.claude/linear.json` wires `/yolo` and `/work` to this project — they pick from
"available now" (status backlog/pending, not blocked).
`.claude/linear.json` (legacy filename, `tracker: gitea`) wires `/yolo` and
`/work` to that tracker — they pick from open, unassigned issues whose
dependencies are all closed. `/blitz <milestone>` sweeps a whole milestone.
Labels carry one value per axis: `area/*`, `type/*`, `difficulty/*`.
`difficulty` drives model selection downstream, so it tracks the shape of the
work, not its size.
## Repo layout
@@ -72,6 +78,10 @@ before changing anything that creates or removes one.
sidebars — line layout, activity colours, duration format, state precedence.
Read it before changing sidebar rendering.
`docs/PREVIEW.md` is the contract for what the content pane shows while the
operator moves the sidebar highlight — what a preview may touch, what ends it,
what commits it. Read it before changing sidebar-driven navigation.
`docs/RESTART.md` is the contract for what clients do when the daemon restarts
or is upgraded under them — the build stamp on `/api/info`, the status
indicator's states, the blocking overlay, grace and poll timings. Read it
+382 -23
View File
@@ -87,6 +87,14 @@ pub struct App {
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
/// mouse) is covered without each one remembering to report it.
focused_session: Option<Uuid>,
/// A sidebar highlight waiting out its debounce before it becomes a
/// preview (`docs/PREVIEW.md`). Walking the list with `j` held down must
/// not open an attach per row it passes over.
pending_preview: Option<(Uuid, std::time::Instant)>,
/// Sessions attached only because they were previewed, oldest first. Kept
/// connected past the preview so stepping back onto a row is instant, and
/// bounded so a long walk doesn't leave a socket per session behind.
preview_lru: Vec<Uuid>,
/// Fingerprint of the last frame drawn. The loop compares a freshly
/// computed key against this and skips the repaint when they match — an
/// idle TUI used to emit a full diff 30 times a second forever, which over
@@ -117,6 +125,14 @@ struct FrameKey {
/// else changed.
const CLOCK_REFRESH: Duration = Duration::from_secs(1);
/// How long a highlight has to stand still before the pane previews it
/// (`docs/PREVIEW.md`). Long enough that a fast walk down the list doesn't
/// attach to every row it crosses, short enough to read as immediate.
const PREVIEW_DELAY: Duration = Duration::from_millis(120);
/// How many preview-only attaches to keep connected behind the current one.
const PREVIEW_BUDGET: usize = 3;
impl std::fmt::Debug for App {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("App")
@@ -196,6 +212,8 @@ impl App {
last_sent_size: HashMap::new(),
clicks: ClickTracker::default(),
focused_session: None,
pending_preview: None,
preview_lru: Vec::new(),
last_frame: None,
last_drawn_at: std::time::Instant::now(),
epoch: 0,
@@ -234,7 +252,9 @@ impl App {
self.epoch = self.epoch.wrapping_add(1);
}
_ = tokio::time::sleep(Duration::from_millis(33)) => {
let effects = self.poll_due_probes_at(std::time::Instant::now());
let now = std::time::Instant::now();
let mut effects = self.poll_due_probes_at(now);
effects.extend(self.poll_due_preview_at(now));
if !effects.is_empty() {
self.epoch = self.epoch.wrapping_add(1);
}
@@ -291,16 +311,26 @@ impl App {
match self.chord.feed(&key, &known) {
ChordResult::Pending => Ok(false),
ChordResult::Resolved(canon) => {
if let Some(act) = self.cfg.keymap.resolve(self.state.mode, &canon) {
if matches!(act, Action::Quit) {
return Ok(true);
let act = self.cfg.keymap.resolve(self.state.mode, &canon);
if matches!(act, Some(Action::Quit)) {
return Ok(true);
}
let pty_owns_keys =
self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane);
match act {
// `Noop` suppresses a binding, and a key with no binding in
// Insert mode is the session's — so a key bound to `Noop`
// reaches the pty too. That is the escape hatch for taking
// a default binding back.
Some(Action::Noop) | None if pty_owns_keys => {
let effects = self.forward_to_session(&key).into_iter().collect();
self.perform(effects).await;
}
let effects = self.dispatch(act);
self.perform(effects).await;
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
{
let effects = self.forward_to_session(&key).into_iter().collect();
self.perform(effects).await;
Some(act) => {
let effects = self.dispatch(act);
self.perform(effects).await;
}
None => {}
}
Ok(false)
}
@@ -332,6 +362,7 @@ impl App {
self.state.sidebar_highlight = Some(SidebarItem::Session(id));
}
}
self.refresh_preview();
true
}
@@ -453,8 +484,15 @@ impl App {
if let Some(previous) = self.state.active_session() {
self.release_session(previous);
}
effects.push(Effect::Attach(sid));
// A previewed session is already attached: committing
// to it keeps that stream rather than reopening one.
if self.emulators.get(sid).is_none() {
effects.push(Effect::Attach(sid));
}
}
// It is the pane's own session now, not a loan from the
// preview budget.
self.preview_lru.retain(|s| *s != sid);
self.state.attach_to_focused(sid);
}
}
@@ -481,9 +519,89 @@ impl App {
}
}
}
// Every action can move the highlight or the focus, and the preview is
// a function of both (`docs/PREVIEW.md`).
self.refresh_preview();
effects
}
/// Recompute what the pane should be previewing. A session highlighted in
/// the sidebar is previewed after [`PREVIEW_DELAY`]; anything else — a
/// group row, the pane holding the keyboard, or the highlight already
/// sitting on the attached session — clears the preview at once.
fn refresh_preview(&mut self) {
let wanted = match (self.state.focus, &self.state.sidebar_highlight) {
(Focus::Sidebar, Some(SidebarItem::Session(sid))) => Some(*sid),
_ => None,
};
match wanted {
Some(sid) if self.state.active_session() != Some(sid) => {
if self.state.preview != Some(sid) {
self.pending_preview = Some((sid, std::time::Instant::now() + PREVIEW_DELAY));
}
}
_ => {
self.pending_preview = None;
self.clear_preview();
}
}
}
/// The debounce elapsed: show the highlighted session, attaching it if
/// nothing already is.
fn poll_due_preview_at(&mut self, now: std::time::Instant) -> Vec<Effect> {
let Some((sid, due)) = self.pending_preview else {
return Vec::new();
};
if now < due {
return Vec::new();
}
self.pending_preview = None;
if self.state.preview == Some(sid) {
return Vec::new();
}
self.state.preview = Some(sid);
// Set outside the input path, so nothing else marks the frame stale.
self.epoch = self.epoch.wrapping_add(1);
let fresh = self.emulators.get(sid).is_none();
if fresh {
self.preview_lru.retain(|s| *s != sid);
self.preview_lru.push(sid);
}
self.trim_preview_budget();
if fresh {
vec![Effect::Attach(sid)]
} else {
Vec::new()
}
}
fn clear_preview(&mut self) {
if self.state.preview.take().is_some() {
self.epoch = self.epoch.wrapping_add(1);
}
self.trim_preview_budget();
}
/// Drop preview-only attaches past the budget. What a tab holds, and what
/// is on screen right now, are never candidates.
fn trim_preview_budget(&mut self) {
while self.preview_lru.len() > PREVIEW_BUDGET {
let held: HashSet<Uuid> = self
.state
.tabs
.iter()
.filter_map(crate::state::Tab::session)
.chain(self.state.preview)
.collect();
let Some(pos) = self.preview_lru.iter().position(|sid| !held.contains(sid)) else {
break;
};
let victim = self.preview_lru.remove(pos);
self.release_session(victim);
}
}
/// Open a stream for `sid` if one isn't already running.
fn attach(&mut self, sid: Uuid) {
if self.emulators.get(sid).is_some() {
@@ -1000,7 +1118,7 @@ impl App {
/// Sample everything a frame depends on. Cheap: two atomic loads, an
/// `RwLock` read of a `u64`, and the backend's size.
fn frame_key(&self, area: ratatui::layout::Size) -> FrameKey {
let pane = self.state.active_session().and_then(|sid| {
let pane = self.state.displayed_session().and_then(|sid| {
self.emulators
.get(sid)
.map(|h| (sid, h.revision(), h.state()))
@@ -1050,21 +1168,31 @@ impl App {
highlight: self.state.sidebar_highlight.clone(),
attached,
};
let active_sid = self.state.active_session();
let pane = active_sid.and_then(|sid| {
let shown_sid = self.state.displayed_session();
let previewing = self.state.preview.is_some();
let pane = shown_sid.and_then(|sid| {
self.emulators.get(sid).map(|h| Pane {
screen: h.screen.clone(),
status: PaneStatus::from(h.state()),
focused: matches!(self.state.focus, Focus::Pane),
// A preview never owns the keyboard, so it never draws a
// cursor either (`docs/PREVIEW.md`).
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
})
});
let pane_title = match active_sid {
Some(sid) => snap
.sessions
.iter()
.find(|session| session.id == sid)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into()),
let pane_title = match shown_sid {
Some(sid) => {
let name = snap
.sessions
.iter()
.find(|session| session.id == sid)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into());
if previewing {
format!("{name} · preview")
} else {
name
}
}
None => "session".into(),
};
@@ -1092,7 +1220,7 @@ impl App {
// Resize propagation: record the active pane's rect, recompute the
// per-dimension min for every attached session, send Resize when
// changed.
if let (Some(sid), Some(rect)) = (active_sid, self.region.pane) {
if let (Some(sid), Some(rect)) = (shown_sid, self.region.pane) {
self.emulators.resize_tracker_mut().set_pane_size(
sid,
"active",
@@ -1501,6 +1629,165 @@ mod tests {
assert_eq!(app.state.overlay(), Some(&OverlayKind::Spawn));
}
/// Seed the catalog with `ids` as sessions in one directory.
fn app_with_sessions(ids: &[Uuid]) -> App {
use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let app = make_app();
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: ids
.iter()
.map(|id| Session {
id: *id,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
env: Default::default(),
metadata: Default::default(),
worktree: None,
status: SessionStatus::Running,
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
blocked_work: None,
})
.collect(),
});
app
}
fn fake_handle() -> crate::session_task::SessionHandle {
use crate::session_task::{SessionHandle, SessionState};
use std::sync::atomic::{AtomicU64, AtomicU8};
use std::sync::{Arc, Mutex};
SessionHandle {
tx: tokio::sync::mpsc::channel(1).0,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: Arc::new(AtomicU64::new(0)),
}
}
fn after_debounce() -> std::time::Instant {
std::time::Instant::now() + PREVIEW_DELAY + Duration::from_millis(1)
}
/// docs/PREVIEW.md: the highlight shows a session in the pane without the
/// tab taking it on.
#[test]
fn highlighting_a_session_previews_it_once_the_debounce_elapses() {
let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext);
// Nothing yet: the highlight has to stand still first.
assert_eq!(app.poll_due_preview_at(std::time::Instant::now()), vec![]);
assert_eq!(app.state.preview, None);
assert_eq!(
app.poll_due_preview_at(after_debounce()),
vec![Effect::Attach(a)]
);
assert_eq!(app.state.preview, Some(a));
assert_eq!(app.state.displayed_session(), Some(a));
// The tab is untouched — the preview is a view, not a layout change.
assert_eq!(app.state.active_tab().session(), None);
}
/// A fast walk down the list attaches to where it stopped, not to every
/// row it crossed.
#[test]
fn walking_past_rows_only_previews_the_one_it_lands_on() {
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let mut app = app_with_sessions(&[a, b]);
app.dispatch(Action::SidebarNext);
app.dispatch(Action::SidebarNext);
let effects = app.poll_due_preview_at(after_debounce());
assert_eq!(effects, vec![Effect::Attach(b)]);
assert_eq!(app.state.preview, Some(b));
}
/// The pane's own session is not something to preview.
#[test]
fn highlighting_the_attached_session_clears_the_preview() {
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let mut app = app_with_sessions(&[a, b]);
app.state.attach_to_focused(a);
app.state.focus = Focus::Sidebar;
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
assert_eq!(app.state.preview, Some(b));
app.state.sidebar_highlight = Some(SidebarItem::Session(a));
app.refresh_preview();
assert_eq!(app.state.preview, None);
assert_eq!(app.state.displayed_session(), Some(a));
}
/// Moving off every session — onto a group row, or into the pane — puts
/// back what the tab was showing.
#[test]
fn leaving_the_session_rows_ends_the_preview() {
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
let mut app = app_with_sessions(&[a, b]);
app.state.attach_to_focused(a);
app.state.focus = Focus::Sidebar;
app.state.sidebar_highlight = Some(SidebarItem::Session(b));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
app.dispatch(Action::FocusPane(crate::action::Direction::Right));
assert_eq!(app.state.preview, None);
assert_eq!(app.state.displayed_session(), Some(a));
}
/// Committing keeps the stream the preview already opened.
#[test]
fn attaching_to_a_previewed_session_reuses_its_stream() {
let a = Uuid::from_u128(1);
let mut app = app_with_sessions(&[a]);
app.dispatch(Action::SidebarNext);
app.poll_due_preview_at(after_debounce());
// Stand in for the attach the effect would have opened.
app.emulators.insert(a, fake_handle());
let effects = app.dispatch(Action::AttachHighlighted);
assert_eq!(effects, vec![]);
assert_eq!(app.state.active_tab().session(), Some(a));
assert_eq!(app.state.preview, None);
// No longer on loan from the preview budget.
assert!(!app.preview_lru.contains(&a));
}
/// Preview-only attaches are kept for a step back, but bounded.
#[test]
fn preview_attaches_are_dropped_past_the_budget() {
let ids: Vec<Uuid> = (1..=(PREVIEW_BUDGET as u128 + 2))
.map(Uuid::from_u128)
.collect();
let mut app = app_with_sessions(&ids);
for id in &ids {
app.state.sidebar_highlight = Some(SidebarItem::Session(*id));
app.refresh_preview();
app.poll_due_preview_at(after_debounce());
app.emulators.insert(*id, fake_handle());
}
assert_eq!(app.preview_lru.len(), PREVIEW_BUDGET);
// The oldest rows previewed are gone; the newest are still warm.
assert!(app.emulators.get(ids[0]).is_none());
assert!(app.emulators.get(*ids.last().unwrap()).is_some());
}
/// Seed the catalog with one session and highlight it.
fn app_with_highlighted_session(sid: Uuid) -> App {
use chrono::Utc;
@@ -2245,6 +2532,78 @@ mod tests {
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
/// Drive one key event through an app in Insert mode with a session
/// attached, and report the bytes that reached that session.
async fn bytes_sent_for(section: &TuiSection, ev: CtEvent) -> Option<Vec<u8>> {
use crate::session_task::{SessionHandle, SessionState};
use std::sync::atomic::{AtomicU64, AtomicU8};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
let sid = Uuid::from_u128(41);
let mut app = App::new(
TuiConfig::load(section).unwrap(),
CatalogStore::default(),
DaemonClient::new("http://127.0.0.1:7070"),
DaemonLink::default(),
);
let (tx, mut rx) = mpsc::channel(4);
app.emulators.insert(
sid,
SessionHandle {
tx,
screen: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
revision: Arc::new(AtomicU64::new(0)),
},
);
app.state.attach_to_focused(sid);
app.state.mode = Mode::Insert;
app.state.focus = Focus::Pane;
app.handle_event(ev).await.unwrap();
match rx.try_recv() {
Ok(SessionInput::Bytes(bytes)) => Some(bytes),
_ => None,
}
}
fn key_event(code: crossterm::event::KeyCode, mods: crossterm::event::KeyModifiers) -> CtEvent {
CtEvent::Key(crossterm::event::KeyEvent::new(code, mods))
}
/// Escape belongs to the session: full-screen apps (vim, Claude Code) read
/// it constantly, so no Insert-mode default may swallow it. The leader is
/// the way back to the sidebar.
#[tokio::test]
async fn escape_reaches_the_attached_session() {
let sent = bytes_sent_for(
&TuiSection::default(),
key_event(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
),
)
.await;
assert_eq!(sent, Some(vec![0x1b]));
}
/// `Noop` suppresses a binding, and in Insert mode a key with no binding is
/// the session's — so binding a key to `Noop` hands it to the pty.
#[tokio::test]
async fn a_noop_insert_binding_forwards_the_key_to_the_session() {
let mut section = TuiSection::default();
section.keymap_insert.insert("ctrl-a".into(), "Noop".into());
let sent = bytes_sent_for(
&section,
key_event(
crossterm::event::KeyCode::Char('a'),
crossterm::event::KeyModifiers::CONTROL,
),
)
.await;
assert_eq!(sent, Some(vec![0x01]));
}
#[test]
fn pane_scrollback_moves_and_user_input_returns_to_live_output() {
use crate::session_task::{SessionHandle, SessionState};
+9 -9
View File
@@ -75,10 +75,13 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
];
/// Default Insert-mode bindings. The leader (`LeaveInsert`) is inserted
/// dynamically by [`TuiConfig::load`] so rebinding it rebinds its Insert
/// escape automatically; `esc` is a second, fixed way back to the sidebar.
const DEFAULT_INSERT: &[(&str, &str)] = &[("esc", "LeaveInsert")];
/// Default Insert-mode bindings — deliberately empty. Every key typed in
/// Insert mode belongs to the session, `esc` included: full-screen apps read
/// it constantly, so binding it here would make it unreachable. The only way
/// back to the sidebar is the leader (`LeaveInsert`), which
/// [`TuiConfig::load`] inserts dynamically so rebinding the leader rebinds
/// the Insert escape with it.
const DEFAULT_INSERT: &[(&str, &str)] = &[];
impl TuiConfig {
/// Build a runtime config from a [`TuiSection`] (typically the `tui`
@@ -232,7 +235,7 @@ mod tests {
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
/// mode only exists while the sidebar is focused, so neither can leak
/// into a pty; `esc` is a second way out of Insert alongside the leader.
/// into a pty; `esc` stays unbound in Insert so it reaches the session.
#[test]
fn aoe_style_defaults_are_bound() {
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
@@ -241,10 +244,7 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "tab"),
Some(Action::AttachHighlighted),
);
assert_eq!(
cfg.keymap.resolve(Mode::Insert, "esc"),
Some(Action::LeaveInsert),
);
assert_eq!(cfg.keymap.resolve(Mode::Insert, "esc"), None);
}
#[test]
+16
View File
@@ -63,6 +63,11 @@ pub struct AppState {
pub mode: Mode,
pub sidebar_visible: bool,
pub sidebar_highlight: Option<SidebarItem>,
/// The session the pane is *previewing* (`docs/PREVIEW.md`): shown because
/// the sidebar highlight is on it, not because a tab holds it. It never
/// enters `tabs`, is never reported to the daemon as focused, and is
/// dropped the moment the highlight moves on.
pub preview: Option<Uuid>,
/// Group paths whose cards are hidden. This is UI state rather than
/// catalog state, so it survives every catalog redraw.
pub collapsed_sidebar_groups: HashSet<PathBuf>,
@@ -91,6 +96,7 @@ impl AppState {
mode: Mode::Normal,
sidebar_visible: true,
sidebar_highlight: None,
preview: None,
collapsed_sidebar_groups: HashSet::new(),
overlay: None,
}
@@ -126,6 +132,9 @@ impl AppState {
/// drop straight into Insert mode so keystrokes go to the session
/// without an extra `i` press. The leader returns to Normal.
pub fn attach_to_focused(&mut self, session_id: Uuid) {
// Committing to what was previewed: the pane owns it now, so the
// preview has nothing left to say.
self.preview = None;
self.active_tab_mut().pane = Some(session_id);
self.focus = Focus::Pane;
self.mode = Mode::Insert;
@@ -150,6 +159,13 @@ impl AppState {
self.active_tab().session()
}
/// What the pane actually draws: the preview when one is up, otherwise the
/// active tab's session. A preview covers the pane without replacing it,
/// so ending one gives back exactly what was there (`docs/PREVIEW.md`).
pub fn displayed_session(&self) -> Option<Uuid> {
self.preview.or_else(|| self.active_session())
}
/// Move the sidebar highlight one step through the rendered item
/// sequence, in the direction of `delta` (positive = down), landing only
/// on items `is_stop` accepts. Only the sign of `delta` matters. Wraps.
+71
View File
@@ -0,0 +1,71 @@
# PREVIEW.md — the sidebar preview contract
One rule for what the content pane shows while the operator is only moving
around the sidebar, so the web client and the TUI behave the same. Read this
before changing either. If the behaviour needs to change, change it here first.
## The rule
Highlighting a session in the sidebar shows that session in the content pane,
as if it were focused, **without focusing it and without changing the layout**.
Moving the highlight elsewhere puts back exactly what was on screen before.
A preview is a view, not a state. Nothing about it is durable:
- The layout is byte-for-byte identical before the preview and after it ends.
- Nothing is persisted while a preview is up (the web client's layout is saved
to `localStorage`; a preview never reaches it).
- The daemon is never told the previewed pane is focused, so a preview never
clears a session's `AwaitingInput` glow (`docs/SIDEBAR.md`).
- The keyboard stays in the sidebar: the previewed terminal is on screen but
does not take focus, so the next `j`/`k` still walks the list.
## Where the preview is shown
| The previewed session is… | What happens |
|---|---|
| Already in a pane, in the active tab | That pane comes forward (its tab's focus moves to it). |
| Already in a pane, in another tab | That tab is brought forward. The active tab is restored when the preview ends. |
| Not in any pane | A throwaway tab carries it. It is removed when the preview ends. |
The throwaway tab never counts as an open session: the tab bar does not appear
because of it, the mobile shell does not switch away from the session list for
it, and the sidebar does not mark the session as open.
## What ends a preview
- The highlight moves to another session — that session is previewed instead.
- The highlight leaves every session (a folder row, or the list losing the
keyboard, or the pointer leaving the list): the previous view comes back.
- Any real layout change (split, close, new tab, tab switch, drag): the preview
is discarded first, so a throwaway tab is never baked into the arrangement.
## What commits a preview
Choosing the previewed session — clicking or pressing Enter on its row,
clicking into its pane, or handing the keyboard to the pane (web: `Tab` from
the list; TUI: the attach binding) — keeps exactly what is on screen. A
throwaway tab becomes a real one; a focus move inside an existing tab stands.
Nothing moves a second time on commit.
## Timing
A preview follows the highlight closely enough to be usable as a way to look
around, and lags a pointer enough not to flash rows crossed on the way
somewhere else.
| Trigger | Delay |
|---|---|
| Keyboard step (`j`/`k`, arrows) | 60 ms |
| Pointer hover (web, mouse only — never touch) | 150 ms |
| Clearing (highlight left every session) | same delay as the trigger that set it |
Touch has no hover, and a tap already opens a session, so touch never previews.
## Cost
A previewed session that no pane holds needs a live attach. Both clients keep
it connected after the preview ends — the web client parks it in the connection
pool's LRU, the TUI keeps its emulator until it falls out of the preview
budget — so stepping back onto a row is instant rather than re-fetching
scrollback.
+12 -5
View File
@@ -32,8 +32,10 @@ export interface AttachConnection {
readonly sessionId: Uuid;
/** The `.attach` element (header + terminal body) to mount into a pane. */
readonly el: HTMLElement;
/** Becomes the visible view: fit to the pane, focus, sync size. */
onMount(): void;
/** Becomes the visible view: fit to the pane, focus, sync size.
* `focus: false` mounts without taking the keyboard — what a sidebar
* preview needs, so j/k keeps working while the pane is on screen. */
onMount(opts?: { focus?: boolean }): void;
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
onUnmount(): void;
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
@@ -309,6 +311,10 @@ export function createAttachConnection(
}
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
@@ -387,7 +393,7 @@ export function createAttachConnection(
reconnectMs = INITIAL_RECONNECT_MS;
setState("connected", "open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (mounted) term.focus();
if (mounted && focusOnMount) term.focus();
};
ws.onmessage = (ev) => {
@@ -472,14 +478,15 @@ export function createAttachConnection(
return {
sessionId,
el,
onMount() {
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 (ws.readyState === WebSocket.OPEN) term.focus();
if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus();
});
},
onUnmount() {
+31
View File
@@ -364,6 +364,11 @@ export interface SidebarOptions {
/** 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, source: "key" | "hover") => void;
}
export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle {
@@ -528,6 +533,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
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;
@@ -539,6 +547,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
navKey = next.dataset.navKey ?? null;
applyNav();
next.scrollIntoView({ block: "nearest" });
opts.onPreview?.(sessionOf(navKey), "key");
};
/** Enter: open the highlighted session, or expand the highlighted folder. */
@@ -563,9 +572,31 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyNav();
};
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
// out: there is no hover there, and a tap already opens the session.
let hovered: string | null = null;
const setHovered = (id: string | null) => {
if (hovered === id) return;
hovered = id;
opts.onPreview?.(id, "hover");
};
treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch") 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, "key"));
/** The session behind the highlighted row, if the highlight is on a card. */
const highlightedSession = (): { row: HTMLElement; session: Session } | undefined => {
const row = rowFor(navKey);
+9 -5
View File
@@ -74,7 +74,7 @@ export class ConnectionPool {
* 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): void {
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
let conn = this.cache.get(id);
if (conn) {
this.cache.delete(id);
@@ -93,9 +93,9 @@ export class ConnectionPool {
}
if (conn.el.parentElement !== pane) {
pane.replaceChildren(conn.el);
conn.onMount();
conn.onMount(opts);
} else if (!this.mounted.has(id)) {
conn.onMount();
conn.onMount(opts);
}
this.mounted.add(id);
this.evict();
@@ -114,13 +114,17 @@ export class ConnectionPool {
/**
* 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 }[]): void {
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);
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
+36 -3
View File
@@ -104,9 +104,26 @@ shellEl.dataset.route = "catalog";
// after the welcome renderer). The hook only fires on user interaction, by which
// point `mv` is assigned.
let mv: MultiView;
// Preview timing (docs/PREVIEW.md): a keyboard step shows almost at once; a
// mouse sweep waits long enough that rows crossed on the way somewhere else
// never flash up. The same delay applies to clearing, so the gap between two
// rows doesn't blink the previous view back.
const PREVIEW_DELAY = { key: 60, hover: 150 } as const;
let previewTimer: number | undefined;
function schedulePreview(sessionId: string | null, source: "key" | "hover"): void {
clearTimeout(previewTimer);
previewTimer = window.setTimeout(() => {
ensureMultiView();
mv?.previewSession(sessionId);
updateShellRoute();
}, PREVIEW_DELAY[source]);
}
const sidebar = renderSidebar(store, {
onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e),
link,
onPreview: schedulePreview,
});
const content = document.createElement("section");
content.className = "content";
@@ -184,7 +201,9 @@ function infoFor(sessionId: string) {
/** Drive the shell's responsive layout off whether any pane is open. */
function updateShellRoute(): void {
const hasPanes = openSessions(layout).size > 0;
// A preview is not an open pane (docs/PREVIEW.md), so the mobile shell does
// not swap the list out for a terminal on the way past a row.
const hasPanes = (mv?.openSessions() ?? openSessions(layout)).size > 0;
shellEl.dataset.route = hasPanes ? "attach" : "catalog";
}
@@ -244,11 +263,18 @@ mv = createMultiView({
onLayoutChange: scheduleSave,
});
let contentShowsMv = false;
function showMultiView(): void {
/** Put the multi-view in the content pane, without rendering it. A preview
* renders once, on its own terms (`docs/PREVIEW.md`); a render here would
* first re-mount the pane it is about to replace, and that mount takes the
* keyboard away from the list being walked. */
function ensureMultiView(): void {
if (!contentShowsMv) {
content.replaceChildren(mv.el);
contentShowsMv = true;
}
}
function showMultiView(): void {
ensureMultiView();
mv.render();
updateShellRoute();
}
@@ -375,7 +401,13 @@ window.addEventListener("keydown", (e) => {
// Tab in the session list hands the keyboard to the pane; Escape in the pane
// hands it back — the pair the TUI gets from `tab` and `esc`.
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
document.addEventListener(FOCUS_PANE_EVENT, () => {
// Handing the keyboard to a previewed pane is choosing it: the preview stops
// being provisional rather than snapping back the moment the list blurs.
const previewed = mv.previewedSessionId();
if (previewed) focusSession(previewed);
pool.focusTerminal();
});
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
@@ -386,6 +418,7 @@ document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
* on `hashchange` alone — a split or drag can move focus without the hash
* changing, leaving a click back onto that session's row silently ignored. */
function focusSession(sessionId: string): void {
clearTimeout(previewTimer);
showMultiView();
mv.openSession(sessionId);
updateShellRoute();
+181 -13
View File
@@ -11,6 +11,7 @@ import {
addTab,
closeTab,
findLeaf,
freshId,
type Dir,
type Layout,
leaves,
@@ -19,6 +20,7 @@ import {
openSessions,
removeLeaf,
splitLeaf,
type Tab,
} from "./layout.js";
import type { AttachInfo } from "./attach.js";
@@ -57,11 +59,22 @@ export interface MultiView {
/** Rebuild the view from the current layout. */
render: () => void;
focusedSessionId: () => string | null;
/** Show `sessionId` in the content pane without committing to it
* (docs/PREVIEW.md): the tab holding it is brought forward, or a throwaway
* tab carries it. `null` puts back whatever was on screen before. */
previewSession: (sessionId: string | null) => void;
/** The session being previewed, or null. */
previewedSessionId: () => string | null;
}
export function createMultiView(opts: MultiViewOptions): MultiView {
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
const persist = () => opts.onLayoutChange?.();
// A preview must never reach localStorage: it is undone the moment the
// highlight moves on, and the layout it would save is not the operator's.
const persist = () => {
if (preview) return;
opts.onLayoutChange?.();
};
const el = document.createElement("div");
el.className = "mv";
@@ -83,8 +96,119 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
render();
});
// ---- preview (docs/PREVIEW.md) ----
// A preview is a temporary *view* change, never a layout change: it records
// exactly what it moved so ending it puts the layout back byte for byte, and
// it neither persists nor reports pane focus to the daemon while it is up.
interface Preview {
sessionId: string;
/** Tab that was active before the preview. */
prevActiveTabId: string;
/** Focused leaf to restore, when the preview stole focus in a real tab. */
prevFocus: { tabId: string; leafId: string | null } | null;
/** Throwaway tab built for a session that no pane was showing. */
tempTabId: string | null;
}
let preview: Preview | null = null;
/// Set while a preview-driven render runs: ending a preview re-mounts the
/// pane that was there, and that mount must not pull the keyboard out of the
/// sidebar the operator is still walking.
let quietRender = false;
/** Render without any mount taking the keyboard. */
function renderQuietly(): void {
quietRender = true;
try {
render();
} finally {
quietRender = false;
}
}
const isTempTab = (tabId: string) => preview?.tempTabId === tabId;
/** Undo the preview's moves. Mutates only — the caller renders. */
function restorePreview(): boolean {
const p = preview;
if (!p) return false;
preview = null;
if (p.tempTabId) {
const i = layout.tabs.findIndex((t) => t.id === p.tempTabId);
if (i >= 0) layout.tabs.splice(i, 1);
} else if (p.prevFocus) {
const t = layout.tabs.find((x) => x.id === p.prevFocus!.tabId);
if (t) t.focusedLeafId = p.prevFocus.leafId;
}
layout.activeTabId = layout.tabs.some((t) => t.id === p.prevActiveTabId)
? p.prevActiveTabId
: layout.tabs[0].id;
return true;
}
/** Keep what the preview is showing: its throwaway tab becomes a real one,
* and the focus it moved stands. Returns true when `sessionId` was the
* session being previewed; any other preview is simply discarded. */
function settlePreview(sessionId: string): boolean {
const p = preview;
if (!p) return false;
if (p.sessionId !== sessionId) {
restorePreview();
return false;
}
preview = null;
if (p.tempTabId) {
const t = layout.tabs.find((x) => x.id === p.tempTabId);
if (t) t.title = `Tab ${layout.tabs.length}`;
}
return true;
}
function previewSession(sessionId: string | null): void {
if (preview?.sessionId === sessionId) return;
if (sessionId === null) {
if (restorePreview()) renderQuietly();
return;
}
restorePreview();
const prevActiveTabId = layout.activeTabId;
const loc = locateSession(layout, sessionId);
if (loc) {
preview = {
sessionId,
prevActiveTabId,
prevFocus: { tabId: loc.tab.id, leafId: loc.tab.focusedLeafId },
tempTabId: null,
};
layout.activeTabId = loc.tab.id;
loc.tab.focusedLeafId = loc.leaf.id;
} else {
// Not on screen anywhere: a throwaway tab carries it, so no existing
// pane is evicted and nothing the operator arranged moves.
const tab: Tab = { id: freshId("t"), title: "Preview", root: null, focusedLeafId: null };
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
layout.tabs.push(tab);
layout.activeTabId = tab.id;
preview = { sessionId, prevActiveTabId, prevFocus: null, tempTabId: tab.id };
}
renderQuietly();
}
/** Any user action that changes the layout for real ends the preview first,
* so it never bakes a throwaway tab into the arrangement. */
function dropPreview(): void {
restorePreview();
}
// ---- highlight helpers ----
/** Sessions the operator actually has open — the preview's throwaway tab
* does not count, or hovering the sidebar would flip the shell's route. */
function realOpenSessions(): Set<string> {
const out = openSessions(layout);
if (preview?.tempTabId) out.delete(preview.sessionId);
return out;
}
function focusedLeafSession(): string | null {
const tab = activeTab(layout);
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
@@ -98,9 +222,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
for (const pane of canvas.querySelectorAll<HTMLElement>(".mv-pane")) {
pane.classList.toggle("is-focused", pane.dataset.leafId === tab.focusedLeafId);
}
pool.setFocus(focusedLeafSession());
// A preview is not a focused pane: reporting it would clear the session's
// `AwaitingInput` glow just because the operator scrolled past it.
if (!preview) pool.setFocus(focusedLeafSession());
sidebar.setHighlights({
open: openSessions(layout),
open: realOpenSessions(),
focused: focusedLeafSession(),
});
}
@@ -168,6 +294,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
pane.addEventListener(
"pointerdown",
(e) => {
// Clicking into a previewed pane is the operator choosing it.
if (preview && leaf.sessionId === preview.sessionId) {
settlePreview(leaf.sessionId);
persist();
}
focusLeaf(leaf.id);
// Drag a pane by its title bar (desktop only — no docking on mobile).
if (isMobile) return;
@@ -252,24 +383,31 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
for (const t of layout.tabs) {
const tab = document.createElement("button");
tab.type = "button";
tab.className = "mv-tab" + (t.id === layout.activeTabId ? " is-active" : "");
tab.className =
"mv-tab" +
(t.id === layout.activeTabId ? " is-active" : "") +
(isTempTab(t.id) ? " is-preview" : "");
const n = leaves(t.root).filter((l) => l.sessionId).length;
const label = document.createElement("span");
label.textContent = t.title + (n ? ` · ${n}` : "");
tab.appendChild(label);
tab.addEventListener("click", () => {
if (isTempTab(t.id)) return; // a preview tab is not a place to go
dropPreview();
if (layout.activeTabId !== t.id) {
layout.activeTabId = t.id;
render();
}
render();
});
if (layout.tabs.length > 1) {
// A preview tab is not the operator's to close: it goes on its own.
if (layout.tabs.length > 1 && !isTempTab(t.id)) {
const close = document.createElement("span");
close.className = "mv-tab-close";
close.textContent = "✕";
close.title = "Close tab";
close.addEventListener("click", (e) => {
e.stopPropagation();
dropPreview();
closeTab(layout, t.id);
render();
});
@@ -283,6 +421,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
add.textContent = "+";
add.title = "New tab";
add.addEventListener("click", () => {
dropPreview();
addTab(layout);
render();
});
@@ -292,12 +431,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// ---- mutations ----
function splitFocused(leafId: string, dir: Dir): void {
dropPreview();
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
render();
}
function closePane(leafId: string): void {
dropPreview();
const tab = activeTab(layout);
removeLeaf(tab, leafId);
if (tab.focusedLeafId === leafId) {
@@ -307,6 +448,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
}
function openSession(sessionId: string): void {
// Opening what is already previewed keeps exactly what is on screen: the
// throwaway tab becomes a real one instead of the session moving again.
if (settlePreview(sessionId)) {
render();
return;
}
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
@@ -337,6 +484,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
/// (#67 review). `newTab` opens its own tab instead of halving whatever the
/// operator was reading (#100) — what the Spawn button asks for.
function openSessionBeside(sessionId: string, placement?: { newTab?: boolean }): void {
if (settlePreview(sessionId)) {
render();
opts.onOpenSessionsChange?.();
return;
}
const loc = locateSession(layout, sessionId);
if (loc) {
layout.activeTabId = loc.tab.id;
@@ -384,13 +536,19 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// A lone tab has nothing to switch between, so the bar is only a strip of
// chrome; CSS hides it until a second tab exists. Spawning still opens its
// own tab (#100), which brings the bar back with the "+" button on it.
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
// The preview's throwaway tab must not make the bar appear and vanish as
// the highlight moves, so it does not count towards "one tab only".
el.classList.toggle(
"mv-solo-tab",
layout.tabs.filter((t) => !isTempTab(t.id)).length === 1,
);
el.classList.toggle("mv-previewing", preview !== null);
if (!tab.root) {
pool.setVisible([]);
pool.setFocus(null);
if (!preview) pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: openSessions(layout), focused: null });
sidebar.setHighlights({ open: realOpenSessions(), focused: null });
persist();
return;
}
@@ -408,7 +566,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = [];
canvas.appendChild(buildTree(tab.root, hosts));
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
// A preview keeps the keyboard in the sidebar: mounting must not focus the
// terminal, or j/k would stop walking the list after the first step.
pool.setVisible(
hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })),
{ focus: !preview && !quietRender },
);
syncFocus();
persist();
}
@@ -431,12 +594,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// Only the focused leaf's session is live on mobile; the rest park.
pool.setVisible(
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
{ focus: !preview && !quietRender },
);
pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null });
if (!preview) pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
}
function closeSession(sessionId: string): void {
dropPreview();
const loc = locateSession(layout, sessionId);
if (!loc) return;
removeLeaf(loc.tab, loc.leaf.id);
@@ -628,6 +793,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
}
function beginSessionDrag(sessionId: string, e: PointerEvent): void {
dropPreview();
runDrag({ kind: "session", sessionId }, e, { deferred: true });
}
@@ -636,9 +802,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
openSession,
openSessionBeside,
closeSession,
openSessions: () => openSessions(layout),
openSessions: realOpenSessions,
beginSessionDrag,
render,
focusedSessionId: focusedLeafSession,
previewSession,
previewedSessionId: () => preview?.sessionId ?? null,
};
}
+218
View File
@@ -0,0 +1,218 @@
// @vitest-environment happy-dom
// Sidebar preview (docs/PREVIEW.md): highlighting a session shows it in the
// content pane without changing the layout the operator built.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { addTab, emptyLayout, type Layout, splitLeaf } from "./layout.js";
import { serializeLayout } from "./persistence.js";
import { createMultiView, type MultiView } from "./multiview.js";
import type { ConnectionPool } from "./connections.js";
import type { SidebarHandle } from "./catalog.js";
function stubFetch(): void {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const body = url.includes("/api/info")
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
: undefined;
return new Response(JSON.stringify(body), { status: 200 });
}),
);
}
interface Spy {
pool: ConnectionPool;
visible: () => string[];
focusCalls: (string | null)[];
mountedWithFocus: (boolean | undefined)[];
}
function fakePool(): Spy {
let visible: string[] = [];
const focusCalls: (string | null)[] = [];
const mountedWithFocus: (boolean | undefined)[] = [];
const pool = {
setVisible(entries: { id: string }[], opts?: { focus?: boolean }) {
visible = entries.map((e) => e.id);
mountedWithFocus.push(opts?.focus);
},
setFocus(id: string | null) {
focusCalls.push(id);
},
} as unknown as ConnectionPool;
return { pool, visible: () => visible, focusCalls, mountedWithFocus };
}
function fakeSidebar(): SidebarHandle {
return {
el: document.createElement("aside"),
setActive() {},
setHighlights() {},
focusList() {},
hasListFocus() {
return false;
},
dispose() {},
};
}
function makeMv(layout: Layout, pool: ConnectionPool, onLayoutChange?: () => void): MultiView {
const mv = createMultiView({
pool,
layout,
sidebar: fakeSidebar(),
infoFor: () => ({ name: "sess", command: "bash" }),
onDisconnect: () => {},
renderWelcome: () => document.createElement("div"),
onLayoutChange,
});
document.body.appendChild(mv.el);
mv.render();
return mv;
}
/** Tab 1 holds `s1`; tab 2 holds `s2` and is the active one. */
function twoTabs(): Layout {
const layout = emptyLayout();
const first = layout.tabs[0];
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
const second = addTab(layout);
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
layout.activeTabId = second.id;
return layout;
}
describe("sidebar preview", () => {
beforeEach(() => {
stubFetch();
document.body.replaceChildren();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("brings a session's own tab forward, then puts the layout back", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s1");
expect(spy.visible()).toEqual(["s1"]);
expect(mv.previewedSessionId()).toBe("s1");
mv.previewSession(null);
expect(spy.visible()).toEqual(["s2"]);
expect(mv.previewedSessionId()).toBeNull();
expect(serializeLayout(layout)).toEqual(before);
});
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s9");
expect(spy.visible()).toEqual(["s9"]);
expect(layout.tabs).toHaveLength(3);
// The throwaway tab is not something the operator has open.
expect(mv.openSessions().has("s9")).toBe(false);
mv.previewSession(null);
expect(layout.tabs).toHaveLength(2);
expect(serializeLayout(layout)).toEqual(before);
});
it("stepping between previews leaves only the newest one on screen", () => {
const layout = twoTabs();
const before = serializeLayout(layout);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s9");
mv.previewSession("s1");
mv.previewSession("s8");
expect(spy.visible()).toEqual(["s8"]);
expect(layout.tabs).toHaveLength(3);
mv.previewSession(null);
expect(serializeLayout(layout)).toEqual(before);
});
it("opening what is previewed keeps it: the throwaway tab becomes real", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
mv.previewSession("s9");
mv.openSession("s9");
expect(mv.previewedSessionId()).toBeNull();
expect(layout.tabs).toHaveLength(3);
expect(mv.openSessions().has("s9")).toBe(true);
expect(spy.visible()).toEqual(["s9"]);
// Nothing the operator had open was evicted to make room.
expect(mv.openSessions().has("s2")).toBe(true);
});
it("never persists, and saves again once the preview is settled", () => {
const layout = twoTabs();
const spy = fakePool();
const saves = vi.fn();
const mv = makeMv(layout, spy.pool, saves);
saves.mockClear();
mv.previewSession("s9");
expect(saves).not.toHaveBeenCalled();
mv.previewSession(null);
expect(saves).toHaveBeenCalled();
});
it("never reports a previewed pane as focused to the daemon", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
spy.focusCalls.length = 0;
mv.previewSession("s1");
mv.previewSession("s9");
expect(spy.focusCalls).toEqual([]);
mv.previewSession(null);
expect(spy.focusCalls).toEqual(["s2"]);
});
it("mounts a preview without taking the keyboard", () => {
const layout = twoTabs();
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
spy.mountedWithFocus.length = 0;
mv.previewSession("s1");
expect(spy.mountedWithFocus).toEqual([false]);
mv.openSession("s1");
expect(spy.mountedWithFocus.at(-1)).toBe(true);
});
it("keeps the tab bar hidden while a lone tab is previewing", () => {
const layout = emptyLayout();
const tab = layout.tabs[0];
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
const spy = fakePool();
const mv = makeMv(layout, spy.pool);
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
mv.previewSession("s9");
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
expect(mv.el.classList.contains("mv-previewing")).toBe(true);
mv.previewSession(null);
expect(mv.el.classList.contains("mv-previewing")).toBe(false);
});
});
+45 -2
View File
@@ -39,8 +39,11 @@ const A = "aaaaaaaa-0000-0000-0000-00000000000a";
const B = "aaaaaaaa-0000-0000-0000-00000000000b";
/** Two sessions in sibling directories: two folder headers, two cards. */
function twoFolders(): { el: HTMLElement; handle: ReturnType<typeof renderSidebar> } {
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]));
function twoFolders(opts: Parameters<typeof renderSidebar>[1] = {}): {
el: HTMLElement;
handle: ReturnType<typeof renderSidebar>;
} {
const handle = renderSidebar(storeWith([session(A, "/one"), session(B, "/two")]), opts);
// Mounted: `focusList` moves real DOM focus, which needs a live document.
document.body.replaceChildren(handle.el);
return { el: handle.el, handle };
@@ -163,3 +166,43 @@ describe("sidebar keyboard navigation", () => {
expect(highlighted(el)).toBe(`session:${A}`);
});
});
describe("sidebar preview requests (docs/PREVIEW.md)", () => {
it("asks for a preview on every keyboard step, and for none once the list blurs", () => {
const seen: [string | null, string][] = [];
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
press(tree(el), "j");
press(tree(el), "j");
expect(seen).toEqual([
[A, "key"],
[B, "key"],
]);
tree(el).dispatchEvent(new FocusEvent("blur"));
expect(seen.at(-1)).toEqual([null, "key"]);
});
it("asks for a preview of the hovered card, and clears it on the way out", () => {
const seen: [string | null, string][] = [];
const { el } = twoFolders({ onPreview: (id, source) => seen.push([id, source]) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toEqual([[A, "hover"]]);
// Moving within the same card is not a new preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
expect(seen.at(-1)).toEqual([null, "hover"]);
});
it("ignores touch, where a tap already opens the session", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
expect(seen).toEqual([]);
});
});
+32
View File
@@ -2883,6 +2883,38 @@ input.leaf-rename:focus {
color: var(--ink);
}
/* Preview (docs/PREVIEW.md): the content pane is showing a session the
operator only highlighted, so it reads as provisional — a dashed tab and a
dashed inset ring on the canvas — and reverts when the highlight moves. */
.mv-tab.is-preview {
border-style: dashed;
font-style: italic;
}
.mv.mv-previewing .mv-canvas::before {
content: "preview";
position: absolute;
top: var(--sp-2);
right: var(--sp-3);
z-index: var(--z-sticky);
padding: 1px var(--sp-2);
border-radius: var(--r-1);
font: 500 var(--fs-11) / 1.5 var(--font-mono);
letter-spacing: 0.04em;
color: var(--accent-ink);
background: var(--accent);
opacity: 0.85;
pointer-events: none;
}
.mv.mv-previewing .mv-canvas::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border: 1px dashed var(--accent-ring);
border-radius: var(--r-1);
z-index: var(--z-sticky);
}
/* canvas + welcome */
.mv-canvas {
position: relative;