feat(maestro): clear AwaitingInput glow once a pane has been focused

A session's rail glows AwaitingInput even after the operator has already
looked at it. Clients now report pane focus (POST /:id/focus); the daemon
clears the glow to Idle after 5s of continuous focus and keeps it cleared
until the session does new work, so a fresh Active cycle is required to
glow again.
This commit is contained in:
Miguel Palhas
2026-09-02 18:03:26 +01:00
parent 63191c7be1
commit f7e7a77912
12 changed files with 465 additions and 2 deletions
+133
View File
@@ -66,6 +66,44 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
} }
} }
/// How long a session's pane must be continuously focused (a client's pane,
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
/// operator has looked, so the rail stops asking for attention.
pub const FOCUS_CLEAR_HOLD: Duration = Duration::from_secs(5);
/// Downgrade `raw` from `AwaitingInput` to `Idle` once the pane has been
/// focused for at least [`FOCUS_CLEAR_HOLD`].
///
/// `suppressed` is the session's sticky acknowledgement, threaded in and
/// mutated in place: once set, it holds even after the pane loses focus, so
/// the state stays `Idle` until the session does new work. Only a fresh
/// `Active` — the session actually running something again — resets it, so
/// the *next* time inference lands on `AwaitingInput` it glows again. Losing
/// and regaining focus alone never re-arms it.
pub fn apply_focus_suppression(
raw: ActivityState,
focused_since: Option<std::time::Instant>,
now: std::time::Instant,
suppressed: &mut bool,
) -> ActivityState {
if raw == ActivityState::Active {
*suppressed = false;
}
if raw != ActivityState::AwaitingInput {
return raw;
}
if let Some(since) = focused_since {
if now.saturating_duration_since(since) >= FOCUS_CLEAR_HOLD {
*suppressed = true;
}
}
if *suppressed {
ActivityState::Idle
} else {
raw
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -118,4 +156,99 @@ mod tests {
Some("short") Some("short")
); );
} }
#[test]
fn awaiting_input_glows_until_focus_hold_is_reached() {
let now = std::time::Instant::now();
let mut suppressed = false;
let since = now - (FOCUS_CLEAR_HOLD - Duration::from_secs(1));
assert_eq!(
apply_focus_suppression(
ActivityState::AwaitingInput,
Some(since),
now,
&mut suppressed
),
ActivityState::AwaitingInput
);
assert!(!suppressed);
}
#[test]
fn awaiting_input_clears_once_focus_hold_is_reached() {
let now = std::time::Instant::now();
let mut suppressed = false;
let since = now - FOCUS_CLEAR_HOLD;
assert_eq!(
apply_focus_suppression(
ActivityState::AwaitingInput,
Some(since),
now,
&mut suppressed
),
ActivityState::Idle
);
assert!(suppressed);
}
#[test]
fn unfocused_awaiting_input_never_clears() {
let now = std::time::Instant::now();
let mut suppressed = false;
assert_eq!(
apply_focus_suppression(ActivityState::AwaitingInput, None, now, &mut suppressed),
ActivityState::AwaitingInput
);
assert!(!suppressed);
}
#[test]
fn suppression_survives_losing_focus() {
let now = std::time::Instant::now();
let mut suppressed = true;
// Focus dropped (`None`) but the session hasn't done new work.
assert_eq!(
apply_focus_suppression(ActivityState::AwaitingInput, None, now, &mut suppressed),
ActivityState::Idle
);
assert!(suppressed);
}
#[test]
fn a_fresh_active_cycle_clears_the_suppression() {
let now = std::time::Instant::now();
let mut suppressed = true;
assert_eq!(
apply_focus_suppression(ActivityState::Active, None, now, &mut suppressed),
ActivityState::Active
);
assert!(!suppressed);
// Blocked again, freshly focused: glows until the hold is reached again.
assert_eq!(
apply_focus_suppression(
ActivityState::AwaitingInput,
Some(now),
now,
&mut suppressed
),
ActivityState::AwaitingInput
);
assert!(!suppressed);
}
#[test]
fn idle_and_error_pass_through_untouched() {
let now = std::time::Instant::now();
let mut suppressed = false;
assert_eq!(
apply_focus_suppression(ActivityState::Idle, Some(now), now, &mut suppressed),
ActivityState::Idle
);
assert_eq!(
apply_focus_suppression(ActivityState::Error, Some(now), now, &mut suppressed),
ActivityState::Error
);
assert!(!suppressed);
}
} }
+25 -1
View File
@@ -20,7 +20,8 @@ use axum::{Json, Router};
use maestro_config::SpawnPreset; use maestro_config::SpawnPreset;
use maestro_protocol::{ use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, PROTOCOL_VERSION, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
PROTOCOL_VERSION,
}; };
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@@ -45,6 +46,7 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity)) .route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
.route(routes::patterns::SESSION_INPUT, post(send_input)) .route(routes::patterns::SESSION_INPUT, post(send_input))
.route(routes::patterns::SESSION_FOCUS, post(set_focus))
.route( .route(
routes::patterns::SESSION, routes::patterns::SESSION,
delete(kill_session).patch(rename_session), delete(kill_session).patch(rename_session),
@@ -275,6 +277,28 @@ async fn send_input(
} }
} }
/// `POST /api/sessions/:id/focus`: a client reports whether its pane is the
/// one currently showing this session — read on the next activity tick to
/// decide whether an `AwaitingInput` glow has been looked at long enough to
/// clear (docs/SIDEBAR.md). Unknown session is 404. Answers `204 No Content`.
async fn set_focus(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<SetFocusRequest>,
) -> Response {
match s.manager.set_focused(id, body.focused).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let info = e.to_error_info();
error_response(
session_error_status(&info.code),
info.code.as_str(),
info.message.as_str(),
)
}
}
}
fn error_response(status: StatusCode, code: &str, message: &str) -> Response { fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(ApiError::new(code, message))).into_response() (status, Json(ApiError::new(code, message))).into_response()
} }
+34 -1
View File
@@ -71,6 +71,15 @@ pub struct SessionRecord {
/// (#115), so this is the observable that says its outcomes are safe to /// (#115), so this is the observable that says its outcomes are safe to
/// assert on. Daemon-local bookkeeping; never rides the wire. /// assert on. Daemon-local bookkeeping; never rides the wire.
pub settled: bool, pub settled: bool,
/// When a client's pane last became the one showing this session
/// (`POST /.../focus`), `None` while unfocused. Daemon-local; never rides
/// the wire.
pub focused_since: Option<std::time::Instant>,
/// Sticky acknowledgement applied by [`crate::activity::apply_focus_suppression`]:
/// once the pane has been focused long enough to clear an `AwaitingInput`
/// glow, this holds through focus loss until the session does new work.
/// Daemon-local; never rides the wire.
pub awaiting_suppressed: bool,
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -456,6 +465,8 @@ impl SessionManager {
bridge, bridge,
reported_activity: None, reported_activity: None,
settled: false, settled: false,
focused_since: None,
awaiting_suppressed: false,
}, },
); );
} }
@@ -659,6 +670,8 @@ impl SessionManager {
bridge, bridge,
reported_activity: None, reported_activity: None,
settled: false, settled: false,
focused_since: None,
awaiting_suppressed: false,
}, },
); );
let _ = self let _ = self
@@ -799,6 +812,17 @@ impl SessionManager {
} }
Ok(()) Ok(())
} }
/// Record whether a client's pane is currently showing `id`. Read by the
/// activity pass's next tick to decide whether an `AwaitingInput` glow
/// has been looked at long enough to clear (`apply_focus_suppression`).
/// An unknown session is [`SessionError::NotFound`].
pub async fn set_focused(&self, id: Uuid, focused: bool) -> Result<(), SessionError> {
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
rec.focused_since = focused.then(std::time::Instant::now);
Ok(())
}
} }
/// Run a blocking `tmux` subprocess call on the blocking thread pool so it /// Run a blocking `tmux` subprocess call on the blocking thread pool so it
@@ -955,10 +979,19 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
// #105: while a report is in force it supplies the state and // #105: while a report is in force it supplies the state and
// nothing here may overwrite it; only an unreported session is // nothing here may overwrite it; only an unreported session is
// decided by inference. // decided by inference.
let activity = match &rec.reported_activity { let raw_activity = match &rec.reported_activity {
Some(report) => report.state, Some(report) => report.state,
None => crate::activity::decide(idle, awaiting), None => crate::activity::decide(idle, awaiting),
}; };
// A pane looked at long enough clears an `AwaitingInput` glow
// (docs/SIDEBAR.md); the acknowledgement is sticky until the
// session does new work again.
let activity = crate::activity::apply_focus_suppression(
raw_activity,
rec.focused_since,
std::time::Instant::now(),
&mut rec.awaiting_suppressed,
);
let foreground = Some(pane.command.clone()); let foreground = Some(pane.command.clone());
// The pane's live cwd, drives the dir-based session tree; empty means // The pane's live cwd, drives the dir-based session tree; empty means
// tmux reported none. // tmux reported none.
+13
View File
@@ -17,6 +17,7 @@ use crate::routes::{self, UrlError};
use crate::{ use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
SetFocusRequest,
}; };
/// Time budget for establishing a TCP connection to the daemon. /// Time budget for establishing a TCP connection to the daemon.
@@ -166,6 +167,18 @@ impl DaemonClient {
Ok(()) Ok(())
} }
/// `POST /api/sessions/:id/focus`: report whether this client's pane is
/// the one currently showing `id` — not merely highlighted in a sidebar.
/// Answers `204 No Content`.
pub async fn set_focus(&self, id: Uuid, focused: bool) -> Result<(), ClientError> {
let req = SetFocusRequest { focused };
let resp = self
.send(Method::POST, &routes::session_focus(id), &[], Some(&req))
.await?;
check(resp).await?;
Ok(())
}
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> { pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
self.get(routes::INFO, &[]).await self.get(routes::INFO, &[]).await
} }
+11
View File
@@ -573,6 +573,17 @@ fn submit_default() -> bool {
true true
} }
/// HTTP `POST /api/sessions/:id/focus` body: a client reports whether its
/// pane is the one currently showing this session — not merely highlighted
/// in a sidebar. The daemon keeps one focus clock per session, last write
/// wins; a second client attached to the same session can race the first
/// one's unfocus, which is accepted as a rare, harmless case (worst outcome
/// is one extra tick before the glow clears).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetFocusRequest {
pub focused: bool,
}
/// Longest session name the daemon stores (#125), in characters. A name is a /// Longest session name the daemon stores (#125), in characters. A name is a
/// sidebar card's line 1; past this it only truncates on every client anyway. /// sidebar card's line 1; past this it only truncates on every client anyway.
pub const MAX_SESSION_NAME_LEN: usize = 64; pub const MAX_SESSION_NAME_LEN: usize = 64;
+15
View File
@@ -54,6 +54,12 @@ pub fn session_input(id: Uuid) -> String {
format!("{SESSIONS}/{id}/input") format!("{SESSIONS}/{id}/input")
} }
/// `POST /api/sessions/:id/focus`: a client reports whether its pane is the
/// one currently showing this session.
pub fn session_focus(id: Uuid) -> String {
format!("{SESSIONS}/{id}/focus")
}
/// `WS /api/attach/:id` — raw PTY bytes. /// `WS /api/attach/:id` — raw PTY bytes.
pub fn attach(id: Uuid) -> String { pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}") format!("{API_PREFIX}/attach/{id}")
@@ -65,6 +71,7 @@ pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id"; pub const SESSION: &str = "/api/sessions/:id";
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity"; pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
pub const SESSION_INPUT: &str = "/api/sessions/:id/input"; pub const SESSION_INPUT: &str = "/api/sessions/:id/input";
pub const SESSION_FOCUS: &str = "/api/sessions/:id/focus";
pub const ATTACH: &str = "/api/attach/:id"; pub const ATTACH: &str = "/api/attach/:id";
} }
@@ -180,6 +187,10 @@ mod tests {
session_input(id()), session_input(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input" "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input"
); );
assert_eq!(
session_focus(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/focus"
);
assert_eq!( assert_eq!(
attach(id()), attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
@@ -206,6 +217,10 @@ mod tests {
patterns::SESSION_INPUT.trim_end_matches(":id/input"), patterns::SESSION_INPUT.trim_end_matches(":id/input"),
session(id()).trim_end_matches(&id().to_string()) session(id()).trim_end_matches(&id().to_string())
); );
assert_eq!(
patterns::SESSION_FOCUS.trim_end_matches(":id/focus"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!( assert_eq!(
patterns::ATTACH.trim_end_matches(":id"), patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string()) attach(id()).trim_end_matches(&id().to_string())
+93
View File
@@ -83,6 +83,12 @@ pub struct App {
region: RegionMap, region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>, last_sent_size: HashMap<Uuid, (u16, u16)>,
clicks: ClickTracker, clicks: ClickTracker,
/// The session, if any, last reported to the daemon as `focused` — the
/// active tab's pane owning the keyboard, never a sidebar highlight.
/// [`Self::sync_focus`] diffs against this every loop tick so every path
/// that can move `Focus::Pane` (attach, tab switch, sidebar toggle,
/// mouse) is covered without each one remembering to report it.
focused_session: Option<Uuid>,
} }
impl std::fmt::Debug for App { impl std::fmt::Debug for App {
@@ -165,6 +171,7 @@ impl App {
region: RegionMap::default(), region: RegionMap::default(),
last_sent_size: HashMap::new(), last_sent_size: HashMap::new(),
clicks: ClickTracker::default(), clicks: ClickTracker::default(),
focused_session: None,
} }
} }
@@ -178,6 +185,7 @@ impl App {
} }
let mut events = EventStream::new(); let mut events = EventStream::new();
loop { loop {
self.sync_focus().await;
self.redraw(terminal)?; self.redraw(terminal)?;
tokio::select! { tokio::select! {
@@ -935,6 +943,27 @@ impl App {
} }
} }
/// Report a pane-focus change to the daemon (docs/SIDEBAR.md's
/// `AwaitingInput` auto-clear). Only `Focus::Pane` on the active tab's
/// session counts — the sidebar highlight is never a "focused pane".
/// Best-effort: a failed report only delays the glow clearing, so
/// nothing here surfaces to the user.
async fn sync_focus(&mut self) {
let wanted = matches!(self.state.focus, Focus::Pane)
.then(|| self.state.active_session())
.flatten();
if wanted == self.focused_session {
return;
}
if let Some(previous) = self.focused_session {
let _ = self.daemon.set_focus(previous, false).await;
}
if let Some(id) = wanted {
let _ = self.daemon.set_focus(id, true).await;
}
self.focused_session = wanted;
}
fn redraw( fn redraw(
&mut self, &mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>, terminal: &mut Terminal<CrosstermBackend<Stdout>>,
@@ -1645,6 +1674,70 @@ mod tests {
assert_eq!(req["worktree"]["create_branch"], json!(true)); assert_eq!(req["worktree"]["create_branch"], json!(true));
} }
/// `sync_focus` reports every path that moves `Focus::Pane`: attaching,
/// switching which session the pane shows, and leaving the pane back to
/// the sidebar. A sidebar highlight alone must report nothing.
#[tokio::test]
async fn sync_focus_reports_pane_focus_changes_to_the_daemon() {
use axum::extract::Path;
use axum::routing::post;
use axum::Json;
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
type PostedFocus = Arc<Mutex<Vec<(Uuid, Value)>>>;
let posted: PostedFocus = Arc::default();
let seen = posted.clone();
async fn set_focus(
axum::extract::State(seen): axum::extract::State<PostedFocus>,
Path(id): Path<Uuid>,
Json(body): Json<Value>,
) -> axum::http::StatusCode {
seen.lock().unwrap().push((id, body));
axum::http::StatusCode::NO_CONTENT
}
let app_router = axum::Router::new().route(
maestro_protocol::routes::patterns::SESSION_FOCUS,
post(set_focus).with_state(seen),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let sid_a = Uuid::from_u128(1);
let sid_b = Uuid::from_u128(2);
let mut app = make_app_at(&base);
// A sidebar highlight alone is not a focused pane.
app.state.sidebar_highlight = Some(SidebarItem::Session(sid_a));
app.sync_focus().await;
assert!(posted.lock().unwrap().is_empty());
// Attaching moves focus into the pane.
app.state.attach_to_focused(sid_a);
app.sync_focus().await;
// Switching which session the pane shows unfocuses the old one first.
app.state.active_tab_mut().pane = Some(sid_b);
app.sync_focus().await;
// Leaving the pane for the sidebar unfocuses the current session.
app.state.focus = Focus::Sidebar;
app.sync_focus().await;
assert_eq!(
*posted.lock().unwrap(),
vec![
(sid_a, json!({"focused": true})),
(sid_a, json!({"focused": false})),
(sid_b, json!({"focused": true})),
(sid_b, json!({"focused": false})),
]
);
}
/// No projects known yet: nothing can resolve, so the keystroke opens a /// No projects known yet: nothing can resolve, so the keystroke opens a
/// spawn form instead of guessing (#67). /// spawn form instead of guessing (#67).
#[tokio::test] #[tokio::test]
+26
View File
@@ -211,6 +211,32 @@ arriving at `POST /api/sessions/:id/activity` is parsed strictly and an unknown
state is a 4xx: an agent posting a state the daemon does not know is a bug in state is a 4xx: an agent posting a state the daemon does not know is a bug in
the agent, and answering 204 would hide it. the agent, and answering 204 would hide it.
## Focus clears `AwaitingInput`
A session's pane — the pane a client's own attach view is showing, never a
sidebar highlight — clears its `AwaitingInput` glow once it has been
**continuously focused for 5 seconds**: the operator has looked, so the rail
stops asking for attention and the card reads as `Idle` instead. This is a
real state change, not a client-side paint-over — the daemon computes it, so
every client watching the session (web, TUI, a second pane on the same
session) sees the same colour.
The acknowledgement is sticky: it survives losing focus, and holds until the
session is genuinely `Active` again — new work, not merely a refocus. Only
after that fresh `Active` cycle does the *next* `AwaitingInput` glow again.
Unfocusing and refocusing alone never re-arms it.
This applies uniformly to `AwaitingInput` from either source — the daemon's
own inference or an agent's report (#105) — since the rail draws them
identically. It does not touch `Error`; that state's own TTL (below) is
unrelated and unaffected by focus.
Each client reports its own pane's focus with `POST /api/sessions/:id/focus`
(`{"focused": bool}`) whenever the pane it is showing changes — on attach, on
switching which session a pane shows, and on leaving the pane. A client
sends `false` for the session it just left before `true` for the session it
lands on, so a click that swaps panes never briefly reports both focused.
## Keyboard walk ## Keyboard walk
`j`/`k` (and the arrow keys) move a highlight through the sidebar. Both clients `j`/`k` (and the arrow keys) move a highlight through the sidebar. Both clients
+16
View File
@@ -91,6 +91,22 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
); );
} }
/** Report whether this client's pane is the one currently showing `id` — not
* merely highlighted in a sidebar. The daemon uses this to clear an
* `AwaitingInput` glow once the pane has been looked at long enough
* (docs/SIDEBAR.md). Best-effort: a failure here only delays the glow
* clearing, so callers don't need to surface it. */
export async function setSessionFocus(id: Uuid, focused: boolean): Promise<void> {
await jsonOrError(
await fetch(routes.sessionFocus(id), {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({ focused }),
}),
);
}
/** List one directory's immediate subdirectories (NAP-329). `path` may be empty, /** List one directory's immediate subdirectories (NAP-329). `path` may be empty,
* `~`, relative, or absolute — the daemon resolves it against its home dir. */ * `~`, relative, or absolute — the daemon resolves it against its home dir. */
export async function listDir(path: string): Promise<DirListing> { export async function listDir(path: string): Promise<DirListing> {
+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionPool } from "./connections.js";
afterEach(() => {
vi.unstubAllGlobals();
});
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
function mockFetch() {
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("ConnectionPool.setFocus", () => {
it("reports the new session focused, none unfocused, the first time", () => {
const fetchMock = mockFetch();
const pool = new ConnectionPool();
pool.setFocus(A);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${A}/focus`);
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({ focused: true });
});
it("unfocuses the previous session and focuses the new one on a switch", () => {
const fetchMock = mockFetch();
const pool = new ConnectionPool();
pool.setFocus(A);
fetchMock.mockClear();
pool.setFocus(B);
expect(fetchMock).toHaveBeenCalledTimes(2);
const bodies = fetchMock.mock.calls.map(([url, init]: unknown[]) => [
url,
JSON.parse((init as RequestInit).body as string),
]);
expect(bodies).toContainEqual([`/api/sessions/${A}/focus`, { focused: false }]);
expect(bodies).toContainEqual([`/api/sessions/${B}/focus`, { focused: true }]);
});
it("reports unfocused when focus clears to null", () => {
const fetchMock = mockFetch();
const pool = new ConnectionPool();
pool.setFocus(A);
fetchMock.mockClear();
pool.setFocus(null);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe(`/api/sessions/${A}/focus`);
expect(JSON.parse(init.body as string)).toEqual({ focused: false });
});
it("is a no-op when the focused session doesn't change", () => {
const fetchMock = mockFetch();
const pool = new ConnectionPool();
pool.setFocus(A);
fetchMock.mockClear();
pool.setFocus(A);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not let a fetch rejection break the pool", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const pool = new ConnectionPool();
expect(() => pool.setFocus(A)).not.toThrow();
// Let the rejected promise's `.catch` settle before the test ends.
await Promise.resolve();
await Promise.resolve();
});
});
+8
View File
@@ -3,6 +3,7 @@ import {
type AttachConnection, type AttachConnection,
type AttachInfo, type AttachInfo,
} from "./attach.js"; } from "./attach.js";
import { setSessionFocus } from "./api.js";
import type { Uuid } from "./types.js"; import type { Uuid } from "./types.js";
/** /**
@@ -122,9 +123,16 @@ export class ConnectionPool {
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);
} }
/** Also tells the daemon which pane is focused (docs/SIDEBAR.md's
* `AwaitingInput` auto-clear) — the sidebar highlight never calls this,
* only a pane actually becoming the visible view. Best-effort: a failed
* report only delays the glow clearing. */
setFocus(id: Uuid | null): void { setFocus(id: Uuid | null): void {
if (id === this.focusedId) return; if (id === this.focusedId) return;
const previous = this.focusedId;
this.focusedId = id; this.focusedId = id;
if (previous !== null) void setSessionFocus(previous, false).catch(() => {});
if (id !== null) void setSessionFocus(id, true).catch(() => {});
this.opts.onFocusChange?.(id); this.opts.onFocusChange?.(id);
} }
+4
View File
@@ -33,6 +33,10 @@ export function attach(id: Uuid): string {
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`; return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
} }
export function sessionFocus(id: Uuid): string {
return `${session(id)}/focus`;
}
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */ /** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string { export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`; return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;