diff --git a/crates/maestro-daemon/src/activity.rs b/crates/maestro-daemon/src/activity.rs index 5ece6ed..4d02d2b 100644 --- a/crates/maestro-daemon/src/activity.rs +++ b/crates/maestro-daemon/src/activity.rs @@ -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, + 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)] mod tests { use super::*; @@ -118,4 +156,99 @@ mod tests { 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); + } } diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index d570ec2..17d3736 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -20,7 +20,8 @@ use axum::{Json, Router}; use maestro_config::SpawnPreset; use maestro_protocol::{ routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, - RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, PROTOCOL_VERSION, + RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest, + PROTOCOL_VERSION, }; use serde::Deserialize; use uuid::Uuid; @@ -45,6 +46,7 @@ pub fn router(state: LocalApiState) -> Router { .route(routes::SESSIONS, get(list_sessions).post(create_session)) .route(routes::patterns::SESSION_ACTIVITY, post(report_activity)) .route(routes::patterns::SESSION_INPUT, post(send_input)) + .route(routes::patterns::SESSION_FOCUS, post(set_focus)) .route( routes::patterns::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, + Path(id): Path, + Json(body): Json, +) -> 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 { (status, Json(ApiError::new(code, message))).into_response() } diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index c2d510f..2705466 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -71,6 +71,15 @@ pub struct SessionRecord { /// (#115), so this is the observable that says its outcomes are safe to /// assert on. Daemon-local bookkeeping; never rides the wire. 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, + /// 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)] @@ -456,6 +465,8 @@ impl SessionManager { bridge, reported_activity: None, settled: false, + focused_since: None, + awaiting_suppressed: false, }, ); } @@ -659,6 +670,8 @@ impl SessionManager { bridge, reported_activity: None, settled: false, + focused_since: None, + awaiting_suppressed: false, }, ); let _ = self @@ -799,6 +812,17 @@ impl SessionManager { } 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 @@ -955,10 +979,19 @@ async fn run_activity_pass(inner: &Arc) { // #105: while a report is in force it supplies the state and // nothing here may overwrite it; only an unreported session is // decided by inference. - let activity = match &rec.reported_activity { + let raw_activity = match &rec.reported_activity { Some(report) => report.state, 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()); // The pane's live cwd, drives the dir-based session tree; empty means // tmux reported none. diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index 01ceb7f..69ed30b 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -17,6 +17,7 @@ use crate::routes::{self, UrlError}; use crate::{ ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, + SetFocusRequest, }; /// Time budget for establishing a TCP connection to the daemon. @@ -166,6 +167,18 @@ impl DaemonClient { 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 { self.get(routes::INFO, &[]).await } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 406b978..d106629 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -573,6 +573,17 @@ fn submit_default() -> bool { 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 /// sidebar card's line 1; past this it only truncates on every client anyway. pub const MAX_SESSION_NAME_LEN: usize = 64; diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 9cf1dec..9583747 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -54,6 +54,12 @@ pub fn session_input(id: Uuid) -> String { 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. pub fn attach(id: Uuid) -> String { format!("{API_PREFIX}/attach/{id}") @@ -65,6 +71,7 @@ pub mod patterns { pub const SESSION: &str = "/api/sessions/:id"; pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity"; 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"; } @@ -180,6 +187,10 @@ mod tests { session_input(id()), "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/input" ); + assert_eq!( + session_focus(id()), + "/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/focus" + ); assert_eq!( attach(id()), "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" @@ -206,6 +217,10 @@ mod tests { patterns::SESSION_INPUT.trim_end_matches(":id/input"), 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!( patterns::ATTACH.trim_end_matches(":id"), attach(id()).trim_end_matches(&id().to_string()) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 6ba960c..929bb2a 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -83,6 +83,12 @@ pub struct App { region: RegionMap, last_sent_size: HashMap, 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, } impl std::fmt::Debug for App { @@ -165,6 +171,7 @@ impl App { region: RegionMap::default(), last_sent_size: HashMap::new(), clicks: ClickTracker::default(), + focused_session: None, } } @@ -178,6 +185,7 @@ impl App { } let mut events = EventStream::new(); loop { + self.sync_focus().await; self.redraw(terminal)?; 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( &mut self, terminal: &mut Terminal>, @@ -1645,6 +1674,70 @@ mod tests { 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>>; + + let posted: PostedFocus = Arc::default(); + let seen = posted.clone(); + async fn set_focus( + axum::extract::State(seen): axum::extract::State, + Path(id): Path, + Json(body): Json, + ) -> 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 /// spawn form instead of guessing (#67). #[tokio::test] diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index f7d5a37..8969793 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -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 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 `j`/`k` (and the arrow keys) move a highlight through the sidebar. Both clients diff --git a/web/src/api.ts b/web/src/api.ts index 50f6e20..a5b63a8 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -91,6 +91,22 @@ export async function killSession(id: Uuid, force = false): Promise { ); } +/** 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 { + 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, * `~`, relative, or absolute — the daemon resolves it against its home dir. */ export async function listDir(path: string): Promise { diff --git a/web/src/connections.test.ts b/web/src/connections.test.ts new file mode 100644 index 0000000..27622de --- /dev/null +++ b/web/src/connections.test.ts @@ -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(); + }); +}); diff --git a/web/src/connections.ts b/web/src/connections.ts index fc8b6e0..abc7b87 100644 --- a/web/src/connections.ts +++ b/web/src/connections.ts @@ -3,6 +3,7 @@ import { type AttachConnection, type AttachInfo, } from "./attach.js"; +import { setSessionFocus } from "./api.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); } + /** Also tells the daemon which pane is focused (docs/SIDEBAR.md's + * `AwaitingInput` auto-clear) — the sidebar highlight never calls this, + * only a pane actually becoming the visible view. Best-effort: a failed + * report only delays the glow clearing. */ setFocus(id: Uuid | null): void { if (id === this.focusedId) return; + const previous = this.focusedId; this.focusedId = id; + if (previous !== null) void setSessionFocus(previous, false).catch(() => {}); + if (id !== null) void setSessionFocus(id, true).catch(() => {}); this.opts.onFocusChange?.(id); } diff --git a/web/src/routes.ts b/web/src/routes.ts index 69ab42c..090b388 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -33,6 +33,10 @@ export function attach(id: Uuid): string { 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. */ export function withPathQuery(route: string, value: string): string { return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;