Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ad2858fd5 | ||
|
|
fa2a26faa9 | ||
|
|
b5feb3c39a | ||
|
|
e12baa23c6 | ||
|
|
63191c7be1 | ||
|
|
acb84b91ef | ||
|
|
c8ab4a531e | ||
|
|
21fc20b30a | ||
|
|
abbd15a8b2 | ||
|
|
1d18e21420 | ||
|
|
38a65e585f |
@@ -608,7 +608,19 @@ impl App {
|
||||
/// daemon never adopts an existing worktree (docs/WORKTREES.md), same
|
||||
/// rule as web/src/spawnSimilar.ts.
|
||||
fn open_spawn_modal(&mut self) {
|
||||
let seed_cwd = self.state.active_session().and_then(|sid| {
|
||||
let mut draft = DraftPane::default();
|
||||
draft.seed_cwd = self.focused_seed_cwd();
|
||||
self.draft = Some(draft);
|
||||
self.state.open_spawn_modal();
|
||||
}
|
||||
|
||||
/// The project of the session the operator was last on — its base
|
||||
/// repository for a worktree session, its cwd otherwise. Shared by the
|
||||
/// plain spawn modal seed and fast-forward's project pick (#67 review):
|
||||
/// both should land on "where I currently am", not just "whatever's
|
||||
/// freshest".
|
||||
fn focused_seed_cwd(&self) -> Option<PathBuf> {
|
||||
self.state.active_session().and_then(|sid| {
|
||||
self.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
@@ -618,11 +630,7 @@ impl App {
|
||||
Some(wt) => wt.base_repo.clone(),
|
||||
None => s.cwd.clone(),
|
||||
})
|
||||
});
|
||||
let mut draft = DraftPane::default();
|
||||
draft.seed_cwd = seed_cwd;
|
||||
self.draft = Some(draft);
|
||||
self.state.open_spawn_modal();
|
||||
})
|
||||
}
|
||||
|
||||
/// Close the form and drop its half-filled state — reopening starts clean,
|
||||
@@ -767,9 +775,11 @@ impl App {
|
||||
info,
|
||||
projects,
|
||||
names,
|
||||
focused_cwd,
|
||||
} = update
|
||||
{
|
||||
self.fast_forward_spawn(info, projects, names).await;
|
||||
self.fast_forward_spawn(info, projects, names, focused_cwd)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let want = match &update {
|
||||
@@ -836,6 +846,7 @@ impl App {
|
||||
/// `/api/projects` walks all of `$HOME`, and pressing the key must not
|
||||
/// freeze keys, frames and attached panes until it returns.
|
||||
fn spawn_fast_forward(&self) {
|
||||
let focused_cwd = self.focused_seed_cwd();
|
||||
let (daemon, http, base, tx) = self.loader_handles();
|
||||
tokio::spawn(async move {
|
||||
let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects());
|
||||
@@ -844,6 +855,7 @@ impl App {
|
||||
info: info.ok(),
|
||||
projects: projects.ok(),
|
||||
names,
|
||||
focused_cwd,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -853,6 +865,7 @@ impl App {
|
||||
info: Option<maestro_protocol::DaemonInfo>,
|
||||
projects: Option<Vec<maestro_protocol::Project>>,
|
||||
names: Vec<String>,
|
||||
focused_cwd: Option<PathBuf>,
|
||||
) {
|
||||
// Keep the home dir: without it the pre-filled draft renders
|
||||
// unabbreviated paths and "Other directory…" seeds `/` instead of
|
||||
@@ -866,8 +879,13 @@ impl App {
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let discovered = projects.unwrap_or_default();
|
||||
let resolved =
|
||||
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
|
||||
let resolved = crate::fast_forward::resolve(
|
||||
&discovered,
|
||||
&presets,
|
||||
names.first().cloned(),
|
||||
Utc::now(),
|
||||
focused_cwd.as_deref(),
|
||||
);
|
||||
if let Some(req) = resolved.build_request() {
|
||||
self.submit_spawn(req).await;
|
||||
return;
|
||||
@@ -1166,6 +1184,7 @@ enum DraftUpdate {
|
||||
info: Option<maestro_protocol::DaemonInfo>,
|
||||
projects: Option<Vec<maestro_protocol::Project>>,
|
||||
names: Vec<String>,
|
||||
focused_cwd: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -26,16 +26,25 @@ pub struct FastForwardDefaults {
|
||||
|
||||
/// Resolve the fast-forward defaults from the daemon's answers. `name` is
|
||||
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the
|
||||
/// caller falls back to the built-ins it already has).
|
||||
/// caller falls back to the built-ins it already has). `focused_cwd` is the
|
||||
/// project of the session the operator was last on ([`crate::app`]'s
|
||||
/// `focused_seed_cwd`) — when it matches a known project, that project wins
|
||||
/// over the global recency ranking, so `N` lands where the operator
|
||||
/// currently is rather than wherever was freshest.
|
||||
pub fn resolve(
|
||||
projects: &[Project],
|
||||
presets: &[SpawnPreset],
|
||||
name: Option<String>,
|
||||
now: DateTime<Utc>,
|
||||
focused_cwd: Option<&std::path::Path>,
|
||||
) -> FastForwardDefaults {
|
||||
let project = crate::projects::sort_projects(projects.to_vec(), now)
|
||||
.into_iter()
|
||||
.next();
|
||||
let project = focused_cwd
|
||||
.and_then(|cwd| projects.iter().find(|p| p.path == cwd).cloned())
|
||||
.or_else(|| {
|
||||
crate::projects::sort_projects(projects.to_vec(), now)
|
||||
.into_iter()
|
||||
.next()
|
||||
});
|
||||
let command = project
|
||||
.as_ref()
|
||||
.and_then(|p| p.last_command.clone())
|
||||
@@ -155,7 +164,13 @@ mod tests {
|
||||
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
||||
];
|
||||
let presets = vec![preset("aider", "aider"), preset("custom", "")];
|
||||
let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now());
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&presets,
|
||||
Some("amber-falcon".into()),
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
r.project.as_ref().unwrap().path,
|
||||
PathBuf::from("/repos/fresh")
|
||||
@@ -176,6 +191,46 @@ mod tests {
|
||||
assert!(wt.create_branch, "fresh branch named after the session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_focused_sessions_project_wins_over_the_freshest_one() {
|
||||
let projects = vec![
|
||||
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
||||
project("/repos/current", 5, Some("zsh")),
|
||||
];
|
||||
let presets = vec![preset("claude", "claude")];
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&presets,
|
||||
Some("n".into()),
|
||||
Utc::now(),
|
||||
Some(std::path::Path::new("/repos/current")),
|
||||
);
|
||||
assert_eq!(
|
||||
r.project.as_ref().unwrap().path,
|
||||
PathBuf::from("/repos/current")
|
||||
);
|
||||
assert_eq!(r.command.as_deref(), Some("zsh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_focused_cwd_falls_back_to_the_freshest_project() {
|
||||
let projects = vec![
|
||||
project("/repos/old", 30, Some("zsh")),
|
||||
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
||||
];
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
Some("n".into()),
|
||||
Utc::now(),
|
||||
Some(std::path::Path::new("/repos/gone")),
|
||||
);
|
||||
assert_eq!(
|
||||
r.project.as_ref().unwrap().path,
|
||||
PathBuf::from("/repos/fresh")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_of_the_most_recent_project_targets_its_base_repo() {
|
||||
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
|
||||
@@ -184,6 +239,7 @@ mod tests {
|
||||
&[preset("zsh", "zsh")],
|
||||
Some("s".into()),
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
let wt = r.build_request().unwrap().worktree.unwrap();
|
||||
assert_eq!(wt.base_repo, PathBuf::from("/repos/main"));
|
||||
@@ -193,7 +249,7 @@ mod tests {
|
||||
fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
|
||||
let projects = vec![project("/repos/p", 0, None)];
|
||||
let presets = vec![preset("custom", ""), preset("claude", "claude")];
|
||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
|
||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now(), None);
|
||||
assert_eq!(r.command.as_deref(), Some("claude"));
|
||||
}
|
||||
|
||||
@@ -201,7 +257,7 @@ mod tests {
|
||||
fn an_empty_last_command_is_no_command_at_all() {
|
||||
let projects = vec![project("/repos/p", 0, Some(""))];
|
||||
let presets = vec![preset("custom", "")];
|
||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
|
||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now(), None);
|
||||
assert_eq!(r.command, None);
|
||||
assert!(r.build_request().is_none());
|
||||
}
|
||||
@@ -213,6 +269,7 @@ mod tests {
|
||||
&[preset("claude", "claude")],
|
||||
Some("n".into()),
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(r.project, None);
|
||||
// No project → no directory → no request, even with command + name.
|
||||
@@ -222,14 +279,26 @@ mod tests {
|
||||
#[test]
|
||||
fn a_missing_generated_name_downgrades_to_a_draft() {
|
||||
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
None,
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
assert!(r.build_request().is_none(), "no name → no immediate spawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
|
||||
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))];
|
||||
let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now());
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&[preset("aider", "aider")],
|
||||
None,
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
|
||||
let mut draft = DraftPane::default();
|
||||
r.apply_to_draft(&mut draft);
|
||||
@@ -251,6 +320,7 @@ mod tests {
|
||||
&[preset("claude", "claude")],
|
||||
Some("amber-falcon".into()),
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
let mut draft = DraftPane::default();
|
||||
named.apply_to_draft(&mut draft);
|
||||
@@ -263,7 +333,13 @@ mod tests {
|
||||
#[test]
|
||||
fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
|
||||
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
None,
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
let mut draft = DraftPane::default();
|
||||
r.apply_to_draft(&mut draft);
|
||||
draft.seed_names(vec!["late-name".into()]);
|
||||
@@ -273,7 +349,13 @@ mod tests {
|
||||
#[test]
|
||||
fn a_last_command_no_preset_advertises_lands_in_custom_text() {
|
||||
let projects = vec![project("/repos/p", 0, Some("nvim -d"))];
|
||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
None,
|
||||
Utc::now(),
|
||||
None,
|
||||
);
|
||||
let mut draft = DraftPane::default();
|
||||
r.apply_to_draft(&mut draft);
|
||||
assert_eq!(draft.command(), "nvim -d");
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
//!
|
||||
//! Each surface carries its own activity colours. A state colour picked for
|
||||
//! the dark card background is not legible on the selection background, so
|
||||
//! the selected card uses the selection surface's own state colours; the dot
|
||||
//! keeps carrying the state either way (docs/SIDEBAR.md).
|
||||
//! the selected card uses the selection surface's own state colours; the
|
||||
//! rail keeps carrying the state either way (docs/SIDEBAR.md).
|
||||
//!
|
||||
//! [`Palette::DARK`] — graphite, no accent hue — is the default.
|
||||
//! [`Palette::LIGHT`] is its light peer and [`Palette::ANSI`] uses
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sidebar widget — session cards per `docs/SIDEBAR.md`.
|
||||
//!
|
||||
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
|
||||
//! Line 2: activity dot + duration in the state's colour, plus the
|
||||
//! Line 1: activity rail + status glyph (● attached / ○ live / ✗ dead) + name.
|
||||
//! Line 2: activity rail + duration in the state's colour, plus the
|
||||
//! adaptive detail, blocked-work, exited, and command fragments.
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
@@ -33,6 +33,9 @@ const META_LINE_AFTER_SECS: i64 = 30;
|
||||
const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
|
||||
const INDENT: &str = " ";
|
||||
const META_INDENT: &str = " ";
|
||||
/// docs/SIDEBAR.md: a one-column activity rail leads the indent gutter on
|
||||
/// both card rows, replacing the old inline dot glyph on the meta line.
|
||||
const RAIL: &str = "▌";
|
||||
|
||||
/// Cards use the selection surface only while the sidebar owns focus. An
|
||||
/// unfocused highlight remains an edge-marked cursor without claiming the
|
||||
@@ -203,7 +206,9 @@ impl Sidebar {
|
||||
);
|
||||
let chevron = Span::styled(chevron, base);
|
||||
let count = Span::styled(count, surface.muted_style().bg(row_bg));
|
||||
let label_budget = width.saturating_sub(prefix.width() + chevron.width() + count.width());
|
||||
// "1 +" is the blank leading column matching the session rows' rail.
|
||||
let label_budget =
|
||||
width.saturating_sub(1 + prefix.width() + chevron.width() + count.width());
|
||||
let mut label = Vec::new();
|
||||
if let Some(stem) = name.stem {
|
||||
label.push(Span::styled(
|
||||
@@ -213,7 +218,9 @@ impl Sidebar {
|
||||
}
|
||||
label.push(Span::styled(name.last, base));
|
||||
|
||||
let mut spans = vec![prefix, chevron];
|
||||
// A blank leading column, matching the width the activity rail adds
|
||||
// to session rows, so folder labels keep aligning under names.
|
||||
let mut spans = vec![Span::styled(" ", base), prefix, chevron];
|
||||
spans.extend(fit_to_width(label, label_budget));
|
||||
spans.push(count);
|
||||
pad_to_width(spans, width, base)
|
||||
@@ -227,11 +234,13 @@ impl Sidebar {
|
||||
};
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let prefix = INDENT.chars().count() + 2;
|
||||
let prefix = 1 + INDENT.chars().count() + 2;
|
||||
let name_budget = width.saturating_sub(prefix);
|
||||
|
||||
let edge = highlighted && !focused;
|
||||
let rail_style = base.fg(activity_color(s.activity, highlighted, focused));
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(RAIL, rail_style),
|
||||
Span::styled(
|
||||
if edge { "▏ " } else { INDENT },
|
||||
if edge {
|
||||
@@ -250,7 +259,8 @@ impl Sidebar {
|
||||
pad_to_width(spans, width, base)
|
||||
}
|
||||
|
||||
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
|
||||
/// The adaptive second line: `[<duration>] [<detail>] <command> [<blocked work> | exited]`,
|
||||
/// led by the activity rail (docs/SIDEBAR.md).
|
||||
fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||
let now = Utc::now();
|
||||
let held = held_for(s, now);
|
||||
@@ -261,7 +271,7 @@ impl Sidebar {
|
||||
let dead = s.status == SessionStatus::Dead;
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let dot_style =
|
||||
let rail_style =
|
||||
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
|
||||
let muted = palette::active()
|
||||
.surface(highlighted && focused)
|
||||
@@ -269,6 +279,7 @@ impl Sidebar {
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
|
||||
let edge = highlighted && !focused;
|
||||
let mut spans = vec![
|
||||
Span::styled(RAIL, rail_style),
|
||||
Span::styled(
|
||||
if edge { " ▏ " } else { META_INDENT },
|
||||
if edge {
|
||||
@@ -277,11 +288,9 @@ impl Sidebar {
|
||||
base
|
||||
},
|
||||
),
|
||||
Span::styled("●", dot_style),
|
||||
];
|
||||
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
|
||||
spans.push(Span::styled(" ", base));
|
||||
spans.push(Span::styled(format_state_duration(d), dot_style));
|
||||
spans.push(Span::styled(format_state_duration(d), rail_style));
|
||||
}
|
||||
let mut tail: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(b) = blocked {
|
||||
@@ -320,8 +329,8 @@ impl Sidebar {
|
||||
budget,
|
||||
));
|
||||
}
|
||||
spans.extend(tail);
|
||||
spans.extend(fit_to_width(vec![command], command_w));
|
||||
spans.extend(tail);
|
||||
spans
|
||||
}
|
||||
}
|
||||
@@ -689,7 +698,7 @@ mod tests {
|
||||
|
||||
/// The additions and deletions are the only coloured fragments: they are
|
||||
/// what a reader scans for, and colouring the rest would make the line
|
||||
/// compete with the activity dot.
|
||||
/// compete with the activity rail.
|
||||
#[test]
|
||||
fn the_diff_colours_only_its_additions_and_deletions() {
|
||||
let mut s = session_named(1, "claude");
|
||||
@@ -735,8 +744,8 @@ mod tests {
|
||||
assert_eq!(content(&all[0]), "sessions");
|
||||
assert!(row_containing(&all, "claude").contains('○'));
|
||||
assert!(
|
||||
all[3].contains('●'),
|
||||
"line 2 must carry the dot: {:?}",
|
||||
all[3].contains(RAIL),
|
||||
"line 2 must carry the rail: {:?}",
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
@@ -752,7 +761,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Rule 1 of docs/SIDEBAR.md: held in state longer than 30s earns the
|
||||
/// meta line, with the floored duration next to the dot.
|
||||
/// meta line, with the floored duration next to the rail.
|
||||
#[test]
|
||||
fn long_held_session_gets_a_meta_line_with_duration() {
|
||||
let mut s = session_named(1, "claude");
|
||||
@@ -765,7 +774,7 @@ mod tests {
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = &all[idx + 1];
|
||||
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
|
||||
assert!(meta.contains(RAIL), "meta line carries the rail: {meta:?}");
|
||||
assert!(meta.contains("1m"), "expected 1m in: {meta:?}");
|
||||
}
|
||||
|
||||
@@ -782,10 +791,10 @@ mod tests {
|
||||
assert_eq!(fmt(7200), "2h");
|
||||
}
|
||||
|
||||
/// The dot and duration carry the activity state's colour; the state word
|
||||
/// is never spelled out (docs/SIDEBAR.md colour table).
|
||||
/// The rail and duration carry the activity state's colour; the state
|
||||
/// word is never spelled out (docs/SIDEBAR.md colour table).
|
||||
#[test]
|
||||
fn meta_line_dot_uses_the_contract_colour_per_state() {
|
||||
fn meta_line_rail_uses_the_contract_colour_per_state() {
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
@@ -808,7 +817,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected card keeps carrying its state on the dot, but in the
|
||||
/// The selected card keeps carrying its state on the rail, but in the
|
||||
/// selection surface's own colours. A state colour picked for the dark
|
||||
/// card background painted onto the selection background is the contrast
|
||||
/// bug this palette fixes — this test fails if it comes back.
|
||||
@@ -845,7 +854,7 @@ mod tests {
|
||||
);
|
||||
assert!(
|
||||
on_selection(p.selection.activity(state)),
|
||||
"{state:?}: the dot must still carry the state on the selected card"
|
||||
"{state:?}: the rail must still carry the state on the selected card"
|
||||
);
|
||||
let ratio = palette::contrast_ratio(p.selection.bg, p.selection.activity(state))
|
||||
.expect("palette roles are RGB");
|
||||
@@ -918,9 +927,11 @@ mod tests {
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = content(&all[idx + 1]);
|
||||
let ellipsis = meta.find('…').expect("truncated command must contain …");
|
||||
let tail = meta.find("~120").expect("diff stat must survive: {meta:?}");
|
||||
assert!(
|
||||
meta.trim_end().ends_with('…'),
|
||||
"truncated command must end with …: {meta:?}"
|
||||
ellipsis < tail,
|
||||
"command must truncate before the diff stat: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
content(&all[idx + 2]).trim().is_empty(),
|
||||
@@ -945,7 +956,7 @@ mod tests {
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = &all[idx + 1];
|
||||
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
|
||||
assert!(meta.contains(RAIL), "meta line carries the rail: {meta:?}");
|
||||
assert!(
|
||||
meta.contains("1m"),
|
||||
"duration precedes the detail: {meta:?}"
|
||||
@@ -1018,7 +1029,7 @@ mod tests {
|
||||
|
||||
/// docs/SIDEBAR.md: a payload predating #102 carries an epoch
|
||||
/// `activity_since` and so contributes no duration — a card must not claim
|
||||
/// the session has been idle since 1970. The dot still renders when some
|
||||
/// the session has been idle since 1970. The rail still renders when some
|
||||
/// other trigger puts line 2 on screen.
|
||||
#[test]
|
||||
fn epoch_activity_since_renders_no_duration() {
|
||||
@@ -1035,10 +1046,10 @@ mod tests {
|
||||
rendered.contains("exited"),
|
||||
"dead session must still show line 2:\n{rendered}"
|
||||
);
|
||||
for line in rendered.lines().filter(|l| l.contains('●')) {
|
||||
for line in rendered.lines().filter(|l| l.contains(RAIL)) {
|
||||
assert!(
|
||||
!line
|
||||
.split('●')
|
||||
.split(RAIL)
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.trim_start()
|
||||
@@ -1101,7 +1112,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_meta_line_keeps_dot_and_truncates_command() {
|
||||
fn narrow_meta_line_keeps_rail_and_truncates_command() {
|
||||
let s = session_named(1, "name");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
@@ -1110,8 +1121,8 @@ mod tests {
|
||||
};
|
||||
let all = rows(&draw(&sb, 9, 6));
|
||||
assert!(
|
||||
all[3].contains('●'),
|
||||
"dot must survive narrow cards: {:?}",
|
||||
all[3].contains(RAIL),
|
||||
"rail must survive narrow cards: {:?}",
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
@@ -1122,7 +1133,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_warning_survives_before_a_truncated_command() {
|
||||
fn blocked_warning_survives_after_a_truncated_command() {
|
||||
let mut s = session_named(1, "name");
|
||||
s.command = "a-program-name-that-cannot-fit".into();
|
||||
s.blocked_work = Some(InTheWay {
|
||||
@@ -1137,7 +1148,12 @@ mod tests {
|
||||
let all = rows(&draw(&sb, 29, 6));
|
||||
assert!(all[3].contains("~1"), "{:?}", all[3]);
|
||||
let meta = content(&all[3]);
|
||||
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
|
||||
let ellipsis = meta.find('…').expect("command must truncate");
|
||||
let tail = meta.find("~1").unwrap();
|
||||
assert!(
|
||||
ellipsis < tail,
|
||||
"diff stat must follow the truncated command: {meta:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+36
-12
@@ -37,23 +37,47 @@ itself is not.
|
||||
Line 2 reads left to right:
|
||||
|
||||
```
|
||||
<dot> [<duration>] [<detail>] [<diff stat> | exited] <command>
|
||||
[<duration>] [<detail>] <command> [<diff stat> | exited]
|
||||
```
|
||||
|
||||
Its minimum content is therefore the dot and the command.
|
||||
Its minimum content is therefore just the command.
|
||||
|
||||
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
|
||||
state's colour, and `<duration>` follows in the same colour when shown. There
|
||||
is no state word and no second badge: the dot exists so the state is still
|
||||
legible when a row is dimmed or the terminal renders the duration in the
|
||||
default fg. The dot is an unconditional part of line 2; the bracketed
|
||||
fragments appear only when their trigger holds.
|
||||
The activity state does not render as an inline glyph on line 2 — a **rail**
|
||||
carries it instead: a coloured bar in the gutter to the left of the card, the
|
||||
space nesting already reserves for indentation. The rail paints for every
|
||||
card, `Idle` included, and is independent of whatever line 2 has to say; a
|
||||
card with nothing else to report still shows its rail next to the bare
|
||||
command. `<duration>` follows in the rail's colour when shown. There is no
|
||||
state word and no second badge: the rail exists so the state is still legible
|
||||
when a row is dimmed or the terminal renders the duration in the default fg.
|
||||
|
||||
In the **web client** the rail is a 5px bar pinned to the row's own left
|
||||
edge, square corners, spanning the full card height edge to edge — it
|
||||
shifts outward with nesting depth along with the row's own padding, since
|
||||
it is positioned relative to the row. It carries motion only for states that
|
||||
need attention (DESIGN.md's "living signals" rule): a hard blink while
|
||||
`AwaitingInput`, a fast flash while `Error`. `Active` and `Idle` are both
|
||||
solid and still — colour alone (green vs. the idle grey) is the signal for
|
||||
`Active`, so it does not also pulse. `prefers-reduced-motion` leaves every
|
||||
state as a solid bar.
|
||||
|
||||
In the **TUI** the rail is a one-column-wide block glyph (`▌`) in the state's
|
||||
palette role, leading the existing indent gutter (`INDENT`/`META_INDENT`) on
|
||||
both the name row and the meta row, so the two lines read as one continuous
|
||||
bar. Folder headers gain a matching blank leading column so labels still
|
||||
align under session names. The TUI has no render cadence to animate against,
|
||||
so its rail is always static — colour is the whole signal there, as it
|
||||
always was.
|
||||
|
||||
Either client's rail carries the accessibility duty the old inline dot did:
|
||||
it is the element whose `title`/`aria-label` names the state, so status still
|
||||
never relies on colour alone.
|
||||
|
||||
`<duration>` renders only once the session has been held in its current
|
||||
activity state longer than **30 seconds** (`activity_since` from #102) — under
|
||||
that threshold the duration is absent and the dot renders alone. A payload
|
||||
that predates #102 (no usable `activity_since`) likewise contributes no
|
||||
duration — the dot still renders. This rule now governs only the duration
|
||||
that threshold the duration is absent and the rail carries the state alone. A
|
||||
payload that predates #102 (no usable `activity_since`) likewise contributes
|
||||
no duration — the rail still renders. This rule now governs only the duration
|
||||
fragment; it no longer decides whether line 2 exists.
|
||||
|
||||
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
|
||||
@@ -61,7 +85,7 @@ fragment; it no longer decides whether line 2 exists.
|
||||
while an agent-reported state is in force (see Precedence): when the report
|
||||
lapses at its TTL or is replaced, the detail goes with it. The detail
|
||||
truncates with `…` ahead of the trailing fragments — a long detail never
|
||||
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
|
||||
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither
|
||||
client ever lets it wrap.
|
||||
|
||||
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
||||
|
||||
@@ -224,9 +224,10 @@ describe("session cards (#107)", () => {
|
||||
activity_since: ago(5_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
const row = rowFor(el, s.id);
|
||||
const line2 = row?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
|
||||
expect(row?.querySelector(".activity-rail")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")).toBeNull();
|
||||
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
|
||||
});
|
||||
@@ -238,9 +239,10 @@ describe("session cards (#107)", () => {
|
||||
activity_since: ago(4 * 60_000 + 30_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
const row = rowFor(el, s.id);
|
||||
const line2 = row?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.dataset.activity).toBe("active");
|
||||
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
|
||||
expect(row?.querySelector(".activity-rail")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
||||
});
|
||||
|
||||
@@ -355,9 +357,9 @@ describe("session cards (#107)", () => {
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLElement;
|
||||
const dot = row.querySelector<HTMLElement>(".activity-dot");
|
||||
expect(dot?.getAttribute("title")).toContain("Awaiting input");
|
||||
expect(dot?.getAttribute("aria-label")).toContain("Awaiting input");
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
+25
-7
@@ -44,6 +44,14 @@ export const FOCUS_PANE_EVENT = "catalog-focus-pane";
|
||||
* ("Spawn similar"). Detail is the session being cloned. */
|
||||
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
|
||||
|
||||
/** 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 —
|
||||
@@ -58,8 +66,9 @@ const collapsed = new Set<string>();
|
||||
* 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 dot's colour carries
|
||||
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
|
||||
// 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",
|
||||
@@ -67,24 +76,29 @@ const STATE_WORDS: Record<string, string> = {
|
||||
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 stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
const duration =
|
||||
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
|
||||
|
||||
const fragments = [
|
||||
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
|
||||
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>` : "",
|
||||
commandFragment(s.command),
|
||||
];
|
||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||
}
|
||||
@@ -156,14 +170,15 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
const running = s.status === "Running";
|
||||
row.dataset.status = running ? "running" : "dead";
|
||||
const name = sessionName(s);
|
||||
const stateWord =
|
||||
STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle;
|
||||
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">${escapeHtml(name)}</span>
|
||||
<span class="leaf-actions">
|
||||
@@ -185,6 +200,9 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
ev.stopPropagation();
|
||||
void requestKill(s, name);
|
||||
});
|
||||
row.addEventListener("click", () => {
|
||||
document.dispatchEvent(new CustomEvent(SESSION_SELECT_EVENT, { detail: s.id }));
|
||||
});
|
||||
row.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
@@ -47,6 +47,30 @@ describe("resolveFastForward (#67)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the focused session's project over the freshest one", () => {
|
||||
const projects = [
|
||||
project({ path: "/repos/fresh", name: "fresh", last_command: "claude" }),
|
||||
project({
|
||||
path: "/repos/current",
|
||||
name: "current",
|
||||
last_activity: "2020-01-01T00:00:00Z",
|
||||
last_command: "zsh",
|
||||
}),
|
||||
];
|
||||
const res = resolveFastForward(projects, presets, "n", "/repos/current");
|
||||
expect(res.project?.path).toBe("/repos/current");
|
||||
expect(res.command).toBe("zsh");
|
||||
});
|
||||
|
||||
it("falls back to the freshest project when the focused cwd is unknown", () => {
|
||||
const projects = [
|
||||
project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }),
|
||||
project({ path: "/repos/fresh", name: "fresh" }),
|
||||
];
|
||||
const res = resolveFastForward(projects, presets, "n", "/repos/gone");
|
||||
expect(res.project?.path).toBe("/repos/fresh");
|
||||
});
|
||||
|
||||
it("falls back to the first non-custom preset when there is no last command", () => {
|
||||
const res = resolveFastForward([project({ last_command: undefined })], presets, "n");
|
||||
expect(res.command).toBe("claude");
|
||||
|
||||
@@ -13,15 +13,20 @@ export interface FastForwardResolution {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** The defaults: most recent project (fresh-first, same ordering the picker
|
||||
* shows), its last command falling back to the first non-custom preset, and
|
||||
* a freshly generated name when the daemon supplied one. */
|
||||
/** The defaults: the currently focused session's project when it's still a
|
||||
* known project, else the most recent one (fresh-first, same ordering the
|
||||
* picker shows) — so `N` lands where the operator currently is, matching
|
||||
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
|
||||
* falls back to the first non-custom preset, and the name is freshly
|
||||
* generated when the daemon supplied one. */
|
||||
export function resolveFastForward(
|
||||
projects: Project[],
|
||||
presets: SpawnPreset[],
|
||||
name?: string,
|
||||
focusedCwd?: string,
|
||||
): FastForwardResolution {
|
||||
const project = sortProjects(projects, new Date())[0];
|
||||
const project =
|
||||
projects.find((p) => p.path === focusedCwd) ?? sortProjects(projects, new Date())[0];
|
||||
const lastCommand = project?.last_command?.trim();
|
||||
const command =
|
||||
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
|
||||
|
||||
+11
-4
@@ -66,12 +66,15 @@ function leafRow(opts: {
|
||||
(row as HTMLAnchorElement).href = "#/kit";
|
||||
row.dataset.status = opts.status;
|
||||
if (opts.active) row.classList.add("is-active");
|
||||
row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`;
|
||||
const depth = opts.indent ?? 0;
|
||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
||||
const flags = [
|
||||
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
|
||||
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
|
||||
].join("");
|
||||
const activity = opts.activity ?? "idle";
|
||||
row.innerHTML = `
|
||||
<span class="activity-rail" data-activity="${activity}" style="left:${depth * 16 + 2}px" title="${activity}"></span>
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name">${opts.name}</span>
|
||||
<span class="leaf-actions">
|
||||
@@ -79,7 +82,7 @@ function leafRow(opts: {
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
|
||||
</span>
|
||||
</span>
|
||||
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-dot" title="${opts.activity}"></span><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
|
||||
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
@@ -201,11 +204,15 @@ export function renderKit(): HTMLElement {
|
||||
const activeLine = h(
|
||||
"span",
|
||||
"card-line2",
|
||||
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`,
|
||||
`<span class="activity-duration">4m</span>`,
|
||||
);
|
||||
activeLine.setAttribute("data-activity", "active");
|
||||
const rail = h("span", "activity-rail");
|
||||
rail.dataset.activity = "active";
|
||||
rail.style.cssText = "position:static;display:inline-block;height:14px;";
|
||||
body.append(
|
||||
specimen("folder-count", h("span", "folder-count", "4")),
|
||||
specimen("activity-rail · active", rail),
|
||||
specimen("card-line2 · active", activeLine),
|
||||
specimen("attach-state · open", h("span", "attach-state", "live")),
|
||||
);
|
||||
@@ -225,7 +232,7 @@ export function renderKit(): HTMLElement {
|
||||
{
|
||||
const { el, body } = section(
|
||||
"Session rows — nested by host, then directory",
|
||||
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); line 2 only when there's something to say — dot + duration in the state's colour, then blocked-work / exited fragments.",
|
||||
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); the activity rail sits in the indent gutter to the left; line 2 carries duration in the state's colour, then blocked-work / exited fragments, whenever there's something to say.",
|
||||
);
|
||||
const list = h("div", "kit-tree");
|
||||
list.append(
|
||||
|
||||
+39
-7
@@ -29,7 +29,12 @@ import { mountHud, type Hud } from "./hud.js";
|
||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
import { loadLayout, saveLayout } from "./persistence.js";
|
||||
import { FOCUS_PANE_EVENT, OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
|
||||
import {
|
||||
FOCUS_PANE_EVENT,
|
||||
OPEN_SPAWN_EVENT,
|
||||
SESSION_SELECT_EVENT,
|
||||
SPAWN_SIMILAR_EVENT,
|
||||
} from "./catalog.js";
|
||||
import { initTheme } from "./theme.js";
|
||||
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
||||
import { installViewportSizing } from "./viewport.js";
|
||||
@@ -257,13 +262,28 @@ function requestOpenSpawn(): void {
|
||||
// beside it is the point.
|
||||
// The session you were last on preselects its project: `n` then Enter spawns
|
||||
// another session there without touching the picker.
|
||||
const focused = mv?.focusedSessionId() ?? null;
|
||||
const current = focused ? store.snapshot().sessions.get(focused) : undefined;
|
||||
const current = focusedSession();
|
||||
openSpawnModal({
|
||||
seed: current ? spawnSimilarProjectSeed(current) : undefined,
|
||||
onSuccess: (session) => mountSpawned(session, { newTab: true }),
|
||||
});
|
||||
}
|
||||
|
||||
/** The session the operator was last on, if any. Shared by the plain spawn
|
||||
* modal seed and fast-forward's project pick: both should land on "where I
|
||||
* currently am". */
|
||||
function focusedSession(): Session | undefined {
|
||||
const focused = mv?.focusedSessionId() ?? null;
|
||||
return focused ? store.snapshot().sessions.get(focused) : undefined;
|
||||
}
|
||||
|
||||
/** The focused session's project — its base repo for a worktree session, its
|
||||
* cwd otherwise (same rule as spawnSimilarProjectSeed). */
|
||||
function focusedProjectCwd(): string | undefined {
|
||||
const s = focusedSession();
|
||||
if (!s) return undefined;
|
||||
return s.worktree ? s.worktree.base_repo : s.cwd;
|
||||
}
|
||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||
document.addEventListener(SPAWN_SIMILAR_EVENT, (ev) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
@@ -298,7 +318,7 @@ async function requestFastForward(): Promise<void> {
|
||||
} catch {
|
||||
/* keep fallbacks */
|
||||
}
|
||||
const resolved = resolveFastForward(discovered, presets, name);
|
||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
try {
|
||||
@@ -355,6 +375,20 @@ window.addEventListener("keydown", (e) => {
|
||||
// Escape in the session list hands the keyboard back to the pane.
|
||||
document.addEventListener(FOCUS_PANE_EVENT, () => pool.focusTerminal());
|
||||
|
||||
/** Open/focus a session and reflect it in the shell chrome. Shared by the
|
||||
* hash router and the sidebar's click-driven path (#133), which can't rely
|
||||
* on `hashchange` alone — a split or drag can move focus without the hash
|
||||
* changing, leaving a click back onto that session's row silently ignored. */
|
||||
function focusSession(sessionId: string): void {
|
||||
showMultiView();
|
||||
mv.openSession(sessionId);
|
||||
updateShellRoute();
|
||||
}
|
||||
|
||||
document.addEventListener(SESSION_SELECT_EVENT, (e) => {
|
||||
focusSession((e as CustomEvent<string>).detail);
|
||||
});
|
||||
|
||||
type Route =
|
||||
| { kind: "catalog" }
|
||||
| { kind: "attach"; sessionId: string }
|
||||
@@ -373,9 +407,7 @@ function dispatch(): void {
|
||||
switch (route.kind) {
|
||||
case "attach":
|
||||
// Deep-link: open/focus the session, then show the layout.
|
||||
showMultiView();
|
||||
mv.openSession(route.sessionId);
|
||||
updateShellRoute();
|
||||
focusSession(route.sessionId);
|
||||
return;
|
||||
case "not-found":
|
||||
shellEl.dataset.route = "notfound";
|
||||
|
||||
+46
-6
@@ -1393,12 +1393,32 @@ input.leaf-rename:focus {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.activity-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
/* docs/SIDEBAR.md: the activity rail replaces the old inline dot. It is
|
||||
pinned to the row's own left edge, full height, square corners — see
|
||||
catalog.ts. Idle stays solid and still on purpose: motion is reserved for
|
||||
states that need attention (DESIGN.md's "living signals" rule). */
|
||||
.activity-rail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
background: currentColor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.activity-rail[data-activity="active"] {
|
||||
color: var(--live);
|
||||
}
|
||||
.activity-rail[data-activity="idle"] {
|
||||
color: var(--border-strong);
|
||||
}
|
||||
.activity-rail[data-activity="awaiting-input"] {
|
||||
color: var(--warn);
|
||||
animation: blink 1.2s steps(1) infinite;
|
||||
}
|
||||
.activity-rail[data-activity="error"] {
|
||||
color: var(--danger);
|
||||
animation: rail-flash 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.activity-duration {
|
||||
@@ -2593,6 +2613,25 @@ input.leaf-rename:focus {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rail-flash {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
45% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -2642,7 +2681,8 @@ input.leaf-rename:focus {
|
||||
}
|
||||
.brand-dot,
|
||||
[data-status="running"] .leaf-status::before,
|
||||
.tally-live::before {
|
||||
.tally-live::before,
|
||||
.activity-rail {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user