Compare commits

..
16 Commits
Author SHA1 Message Date
Miguel Palhas cd5bbcfcb1 Merge remote-tracking branch 'origin/main' into timbre-2
ci / web (push) Failing after 1m7s
ci / flake (push) Successful in 3m55s
ci / rust (push) Has been cancelled
2026-09-12 10:59:49 +01:00
Miguel PalhasandClaude Opus 4.6 cbbf526fc4 fix(maestro): persist auto_approve across daemon restarts
Structured sessions spawned with auto_approve lost the flag on daemon
restart because it lived only in the in-memory SessionRecord. Add it to
the Session struct (sidecar JSON) so recovery reads it back instead of
hardcoding false.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 10:59:44 +01:00
Miguel Palhas 9f5b71d563 Merge remote-tracking branch 'origin/main' into aeolian
ci / web (push) Failing after 29s
ci / rust (push) Failing after 51s
ci / flake (push) Successful in 3m9s
2026-09-12 10:42:10 +01:00
Miguel PalhasandClaude Opus 4.6 de603ccd20 feat(maestro): split tool groups on text boundaries
Tool rows interleaved with agent text now produce separate
collapsed groups instead of one per turn. The timeline reads
text → group → text → group, matching chronological order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 10:42:06 +01:00
Miguel Palhas c6af78e9dd feat(maestro): concatenate queued structured prompts into one turn
ci / web (push) Failing after 6s
ci / rust (push) Failing after 54s
ci / flake (push) Successful in 2m59s
Closes #257
2026-09-12 10:12:36 +01:00
Miguel PalhasandClaude Opus 4.6 7ab503e4f5 fix(maestro): update e2e test for force-kill worktree removal
ci / web (push) Failing after 6s
ci / rust (push) Failing after 53s
ci / flake (push) Successful in 2m47s
1b2e5f5 changed force kill to remove dirty worktrees but left the
test asserting the old keep-on-disk behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-11 08:38:51 +01:00
Miguel Palhas eaf11a7696 Merge branch 'maestoso'
ci / web (push) Failing after 27s
ci / rust (push) Failing after 1m1s
ci / flake (push) Successful in 3m35s
2026-09-10 23:15:45 +01:00
Miguel Palhas 4738233580 Merge remote-tracking branch 'origin/main' into lento
ci / web (push) Failing after 27s
ci / rust (push) Failing after 50s
ci / flake (push) Has been cancelled
2026-09-10 21:55:19 +01:00
Miguel PalhasandClaude Opus 4.6 cb25320ea0 feat(maestro): keep agent text visible, collapse only tool calls
Agent text and thought rows stay outside the collapsed tool group as
standalone transcript items instead of being absorbed into it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:55:06 +01:00
Miguel Palhas ff52dd9812 Merge branch 'lyre'
ci / web (push) Successful in 51s
ci / rust (push) Failing after 1m2s
ci / flake (push) Successful in 3m24s
2026-09-10 21:53:34 +01:00
Miguel PalhasandClaude Opus 4.6 f3a5cad789 feat(maestro): widen sidebar activity rail from 5px to 7px
Makes the coloured state bars more visible at a glance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:53:18 +01:00
Miguel Palhas fffd2a55f3 Merge remote-tracking branch 'origin/main' into maestoso 2026-09-10 21:52:26 +01:00
Miguel PalhasandClaude Opus 4.6 563a35d2ec fix(maestro): restore sidebar fill, pane title, and focus dimming after React port
ci / web (push) Failing after 6s
ci / rust (push) Failing after 48s
ci / flake (push) Has been cancelled
The React port replaced CSS class names with Tailwind utilities, breaking
three visual contracts:

- SessionCard used inline bg-* classes instead of .is-active/.is-open/
  .is-previewing that the styles.css cascade targets.
- PaneTitle used className "at-title" instead of "attach-title", so font,
  overflow, and child layout rules never applied.
- MultiView's Pane never applied .is-focused or .is-preview, so every
  pane matched :not(.is-focused) and was dimmed to 0.45 opacity.

Closes #256

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:51:53 +01:00
Miguel PalhasandClaude Opus 4.6 9dead5652e fix(maestro): reconnect attach/structured sockets on Android resume
The visibility recovery only reconnected the catalog and daemon-link
sockets. Per-pane attach and structured WebSockets relied on backoff
timers that Android suspends alongside the sockets, leaving the
terminal disconnected after backgrounding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 21:50:29 +01:00
Miguel Palhas cd2184b128 Merge remote-tracking branch 'origin/main' into modulation-2
ci / web (push) Failing after 7s
ci / rust (push) Failing after 1m3s
ci / flake (push) Successful in 3m30s
2026-09-10 17:29:49 +01:00
Miguel PalhasandClaude Opus 4.6 1b2e5f5f05 feat(maestro): force kill deletes dirty worktrees
Previously --force left the worktree on disk so the operator could
inspect it. Now it removes the worktree unconditionally — the
confirmation prompt already shows the breakdown, so the decision
happens before the delete, not after.

Natural exit still leaves dirty worktrees on disk (no operator
confirmation in that path).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-10 17:26:04 +01:00
27 changed files with 245 additions and 217 deletions
+1 -1
View File
@@ -133,7 +133,7 @@ pub struct KillArgs {
/// Session ID to kill. /// Session ID to kill.
pub session_id: Uuid, pub session_id: Uuid,
/// End the session even when its worktree holds unsaved work (#81). The /// End the session even when its worktree holds unsaved work (#81). The
/// worktree is left on disk — never removed. /// worktree is removed along with the session.
#[arg(long)] #[arg(long)]
pub force: bool, pub force: bool,
} }
+1
View File
@@ -345,6 +345,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
+13 -38
View File
@@ -1404,6 +1404,7 @@ impl SessionManager {
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
view: req.view, view: req.view,
auto_approve: req.auto_approve,
}; };
if let Err(e) = self.inner.sidecar.write(&session) { if let Err(e) = self.inner.sidecar.write(&session) {
@@ -1545,15 +1546,10 @@ impl SessionManager {
/// uncommitted tracked changes, untracked non-ignored files, or commits /// uncommitted tracked changes, untracked non-ignored files, or commits
/// reachable from no remote-tracking ref. Nothing is killed and nothing /// reachable from no remote-tracking ref. Nothing is killed and nothing
/// is removed; the error names what blocked it and how much of it there /// is removed; the error names what blocked it and how much of it there
/// is. The session stays as the operator's handle on their own unfinished /// is.
/// work, so ending a session that still matters becomes a deliberate
/// `--force`, which leaves the tree alone rather than destroying it
/// (WORKTREES.md).
/// ///
/// With `force` (#81) the refusal — and nothing else — is overridden: /// With `force` (#81) the refusal is overridden: the session ends and
/// the session ends and the guarded worktree is left on disk, never /// the worktree is removed, even when it holds unsaved work.
/// removed. A tree whose guard is clear never needed the override; force
/// removes it as usual.
/// ///
/// This is also how an already-dead session is dismissed. tmux is only /// This is also how an already-dead session is dismissed. tmux is only
/// asked to kill something it still has — calling `kill-session` on a /// asked to kill something it still has — calling `kill-session` on a
@@ -1588,7 +1584,7 @@ impl SessionManager {
sessions.remove(&id) sessions.remove(&id)
}; };
let record = removed.ok_or(SessionError::NotFound(id))?; let record = removed.ok_or(SessionError::NotFound(id))?;
cleanup_record(&self.inner, id, record, alive, guarded).await; cleanup_record(&self.inner, id, record, alive).await;
Ok(()) Ok(())
} }
@@ -1683,6 +1679,7 @@ impl SessionManager {
// Unknown on adoption without a sidecar; matches every // Unknown on adoption without a sidecar; matches every
// other field's forward-compat default. // other field's forward-compat default.
view: maestro_protocol::SessionView::Terminal, view: maestro_protocol::SessionView::Terminal,
auto_approve: false,
}, },
}; };
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
@@ -1720,7 +1717,7 @@ impl SessionManager {
acp_events: None, acp_events: None,
structured_activity_watermark: None, structured_activity_watermark: None,
acp_event_pump: None, acp_event_pump: None,
auto_approve: false, auto_approve: session.auto_approve,
pending_prompt: None, pending_prompt: None,
}, },
); );
@@ -1899,15 +1896,13 @@ impl SessionManager {
acp_events, acp_events,
structured_activity_watermark: None, structured_activity_watermark: None,
acp_event_pump: None, acp_event_pump: None,
// Recovery cannot know the original preset's flag; the auto_approve: session.auto_approve,
// operator re-enables it by restarting with the preset.
auto_approve: false,
pending_prompt: None, pending_prompt: None,
}, },
); );
if let Some((acp, events)) = event_pump { if let Some((acp, events)) = event_pump {
let done = start_acp_event_pump(&self.inner, id, &acp, events.clone()); let done = start_acp_event_pump(&self.inner, id, &acp, events.clone());
start_acp_permission_pump(&self.inner, id, &acp, events, false); start_acp_permission_pump(&self.inner, id, &acp, events, session.auto_approve);
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) { if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
record.acp_event_pump = Some(done); record.acp_event_pump = Some(done);
} }
@@ -2972,8 +2967,8 @@ async fn mark_session_dead_with_worktree_settlement(
/// Apply the exit rule (#82) to a vanished session's worktree: probe what /// Apply the exit rule (#82) to a vanished session's worktree: probe what
/// would be lost through the kill's own predicate, then remove a pristine /// would be lost through the kill's own predicate, then remove a pristine
/// tree. Anything holding unsaved work is left exactly where it is — the /// tree. Anything holding unsaved work is left on disk — there is no
/// same thing a forced kill does with a guarded tree (#81). /// operator confirmation on the natural-exit path.
/// ///
/// Returns whether the worktree was fully accounted for. When it wasn't, /// Returns whether the worktree was fully accounted for. When it wasn't,
/// nothing was destroyed and the caller keeps the reference on the record. /// nothing was destroyed and the caller keeps the reference on the record.
@@ -3028,17 +3023,7 @@ async fn settle_worktree_on_exit(
/// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't /// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the /// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
/// liveness reaper (tmux already lost the session). /// liveness reaper (tmux already lost the session).
/// async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kill_tmux: bool) {
/// `keep_worktree` is `true` for a forced kill (#81) over a guarded tree: the
/// session ends but the worktree is left on disk, so only the removal is
/// skipped.
async fn cleanup_record(
inner: &Arc<Inner>,
id: Uuid,
record: SessionRecord,
kill_tmux: bool,
keep_worktree: bool,
) {
if kill_tmux { if kill_tmux {
let tmux = inner.tmux.clone(); let tmux = inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await; let _ = tmux_blocking(move || tmux.kill_session(id)).await;
@@ -3059,17 +3044,7 @@ async fn cleanup_record(
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar"); tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
} }
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) { if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if keep_worktree { if let Err(e) = inner.worktree_mgr.remove(base, path) {
tracing::info!(
%id,
path = %path.display(),
"forced kill: worktree left on disk"
);
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
// The kill already passed the unsaved-work guard (#79), so this
// removal is unconditional; the manager's own nested-worktree
// refusal is all that can still leave a tree on disk, and it
// says where.
tracing::warn!( tracing::warn!(
%id, %id,
path = %path.display(), path = %path.display(),
+1
View File
@@ -34,6 +34,7 @@ fn sample(id: Uuid) -> Session {
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
view: SessionView::Terminal, view: SessionView::Terminal,
auto_approve: false,
} }
} }
+4 -4
View File
@@ -777,7 +777,7 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
other => panic!("expected a kill_guarded refusal, got {other:?}"), other => panic!("expected a kill_guarded refusal, got {other:?}"),
} }
// Force overrides the refusal and nothing else: session gone, tree kept. // Force overrides the refusal: session gone, tree removed.
client.kill_session_forced(s.id).await.expect("forced kill"); client.kill_session_forced(s.id).await.expect("forced kill");
assert!( assert!(
@@ -789,12 +789,12 @@ async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
.any(|x| x.id == s.id), .any(|x| x.id == s.id),
"the forced kill ends the session" "the forced kill ends the session"
); );
assert!(wt.is_dir(), "a guarded worktree must stay on disk"); assert!(!wt.is_dir(), "a forced kill removes the worktree");
assert!( assert!(
git_worktree_list(base.path()) !git_worktree_list(base.path())
.iter() .iter()
.any(|l| l.contains("feature/local")), .any(|l| l.contains("feature/local")),
"the kept tree stays registered in git" "the removed tree is unregistered from git"
); );
} }
+1
View File
@@ -744,6 +744,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
+7
View File
@@ -132,6 +132,12 @@ pub struct Session {
/// the two are minted by different processes with different lifetimes. /// the two are minted by different processes with different lifetimes.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub acp_session_id: Option<String>, pub acp_session_id: Option<String>,
/// When `true` the daemon auto-approves every ACP permission request for
/// this session instead of surfacing them to the client. Set at spawn time
/// from the preset or the `CreateSessionRequest`; persisted so it survives
/// a daemon restart.
#[serde(default)]
pub auto_approve: bool,
} }
/// A session's process shape (#133): a plain terminal, or a structured agent /// A session's process shape (#133): a plain terminal, or a structured agent
@@ -1054,6 +1060,7 @@ mod tests {
last_focused_at: None, last_focused_at: None,
view: SessionView::Terminal, view: SessionView::Terminal,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
+1
View File
@@ -50,6 +50,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
+6
View File
@@ -2216,6 +2216,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
}) })
.collect(), .collect(),
}); });
@@ -2399,6 +2400,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
}], }],
}); });
app.dispatch(Action::SidebarNext); app.dispatch(Action::SidebarNext);
@@ -2428,6 +2430,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
@@ -3590,6 +3593,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: focused, last_focused_at: focused,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
}; };
let older = Utc::now() - chrono::Duration::seconds(60); let older = Utc::now() - chrono::Duration::seconds(60);
app.catalog.apply_event(CatalogEvent::Snapshot { app.catalog.apply_event(CatalogEvent::Snapshot {
@@ -3870,6 +3874,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
}], }],
}); });
app.dispatch(Action::KillHighlighted); app.dispatch(Action::KillHighlighted);
@@ -3907,6 +3912,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
}], }],
}); });
} }
+1
View File
@@ -225,6 +225,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
+1
View File
@@ -811,6 +811,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
+2
View File
@@ -502,6 +502,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
} }
} }
@@ -1272,6 +1273,7 @@ mod tests {
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
acp_session_id: None, acp_session_id: None,
auto_approve: false,
}; };
let sb = Sidebar { let sb = Sidebar {
sessions: vec![s], sessions: vec![s],
+1 -1
View File
@@ -70,7 +70,7 @@ 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 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. 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 In the **web client** the rail is a 7px bar pinned to the row's own left
edge, square corners, spanning the full card height edge to edge — it 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 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 it is positioned relative to the row. It carries motion only for states that
+11 -14
View File
@@ -189,20 +189,17 @@ choices, not daemon ones.
presentation for `Notice`. `Plan` is the exception: both clients omit it presentation for `Notice`. `Plan` is the exception: both clients omit it
from the transcript; the TUI may show the current plan in its status line, from the transcript; the TUI may show the current plan in its status line,
while the web client deliberately has no stats or plan rail. while the web client deliberately has no stats or plan rail.
- The web client groups two or more tool rows after the same `User` row into - The web client groups each contiguous run of two or more tool rows into a
one collapsed turn activity block. `AgentText` and `AgentThought` rows that collapsed activity block. An `AgentText` or `AgentThought` row between
appear *between* tool rows in the same turn are absorbed into the group tools splits them into separate groups, so the timeline reads
(intermediate narration); the last `AgentText` with no subsequent tool stays text → group → text → group rather than lumping every tool in a turn into
outside as the prominent final answer. While a turn is still active one box. Text and thought rows stay outside groups as standalone transcript
(responding or thinking), the most recent trailing text or thought row is items — the operator sees all reasoning without expanding. Resolved
kept outside as visible narration — the operator always sees the agent's approval rows between tools within a run are absorbed into the group. The
latest reasoning without opening the group. It is replaced by the next text collapsed header reads "N tool calls · Label 'arg'" where the label and
row when one arrives, and absorbed into the group when the turn ends (unless argument come from the last tool row's `splitToolTitle` — the operator
it is `AgentText`, which stays outside as the final answer). The collapsed sees which command is current at a glance. Opening the group reveals tool
header reads "N tool calls · Label 'arg'" where the label and argument come rows in chronological order.
from the last tool row's `splitToolTitle` — the operator sees which command
is current at a glance. Opening the group reveals tools and intermediate
text in chronological order, with the text rendered in a subdued style.
- The web client's user, agent, thought, tool-detail, and notice text blocks - The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated. copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live - The web client consumes `available_commands_update` notices as the live
+10 -25
View File
@@ -75,27 +75,17 @@ The point of refusing rather than cleaning up is that the session stays as the
operator's handle on their own unfinished work. A session that is killed while operator's handle on their own unfinished work. A session that is killed while
its worktree still matters leaves a directory nobody is looking at. its worktree still matters leaves a directory nobody is looking at.
A consequence, accepted deliberately: a feature branch almost always has unpushed
commits, so `--force` is the ordinary way to end a session, not an exception.
That is only safe because force never removes what blocked.
### `--force` ### `--force`
`--force` overrides the refusal and nothing else. It ends the session and `--force` overrides the refusal and **removes the worktree**, even when it holds
**leaves the worktree on disk**, untouched. Force never removes a tree that unsaved work. The operator sees the breakdown in the confirmation prompt and
would have blocked. A tree whose guard is clear never needed the override, so a decides; once confirmed, the tree is gone.
forced kill removes it as usual — force changes what happens to blocked work,
not ordinary cleanup.
In the web client the kill button sends the unforced `DELETE` straight away — In the web client the kill button sends the unforced `DELETE` straight away —
there is no pre-confirmation, because the daemon is the one that decides. Only there is no pre-confirmation, because the daemon is the one that decides. Only
the refusal raises a prompt, and that prompt names what is in the way and offers the refusal raises a prompt, and that prompt names what is in the way and offers
"Force kill", which repeats the request with `force=true`. "Force kill", which repeats the request with `force=true`.
Ending a session and destroying its work are two separate acts. Maestro only
performs the first one. Removing a tree it left behind is the operator's own
`git worktree remove`, chosen while looking at exactly what would be lost.
### Exiting on its own ### Exiting on its own
A session that ends by itself — the command finished, the operator typed `exit` — A session that ends by itself — the command finished, the operator typed `exit` —
@@ -113,17 +103,12 @@ once. A removed tree drops off the record, so dismissing the session afterwards
does not refuse: there is no guard left to trip. does not refuse: there is no guard left to trip.
A tree that was left alone stays attached to the dead record, where dismissing it A tree that was left alone stays attached to the dead record, where dismissing it
later goes through `--force`, which leaves it on disk too. The same holds when later goes through `--force`, which removes it. The same holds when the probe
the probe itself fails: nothing is destroyed. itself fails: nothing is destroyed.
## Nothing is removed on the operator's behalf ## Trees left behind by natural exit
There is no store of worktrees the daemon is holding, no list endpoint, and no A forced kill always removes the worktree. A natural exit with unsaved work
reclaim command. A tree left behind by a forced kill or a dirty exit is an leaves the tree on disk — there is no operator confirmation in that path. Those
ordinary git worktree on disk; `git worktree list` finds it and trees are ordinary git worktrees; `git worktree list` finds them and
`git worktree remove` removes it. `git worktree remove` removes them.
That is deliberate. A tracked list of trees pending deletion is a second place
the truth can live, and it goes stale the moment somebody removes a directory by
hand. Git already knows which worktrees exist, and removing one is not a
decision Maestro is better placed to make.
+1 -1
View File
@@ -105,7 +105,7 @@ describe("MultiView", () => {
expect(screen.getByText("Tab 1 · 1")).toBeTruthy(); expect(screen.getByText("Tab 1 · 1")).toBeTruthy();
const pane = document.querySelector('[data-session-id="s9"]'); const pane = document.querySelector('[data-session-id="s9"]');
expect(pane).toBeTruthy(); expect(pane).toBeTruthy();
expect(pane!.querySelector(".bg-preview-tint")).toBeTruthy(); expect(pane!.classList.contains("is-preview")).toBe(true);
expect(screen.getByText("preview")).toBeTruthy(); expect(screen.getByText("preview")).toBeTruthy();
}); });
+4 -6
View File
@@ -188,8 +188,9 @@ function Pane({
return ( return (
<div <div
className={ className={
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " + "mv-pane flex h-full min-h-0 min-w-0 flex-col" +
(isFocused && !isPreview ? "border-accent" : "border-border-strong") (isFocused ? " is-focused" : "") +
(isPreview ? " is-preview" : "")
} }
data-leaf-id={leaf.id} data-leaf-id={leaf.id}
data-session-id={leaf.sessionId ?? undefined} data-session-id={leaf.sessionId ?? undefined}
@@ -201,10 +202,7 @@ function Pane({
}} }}
> >
<div <div
className={ className="mv-pane-header attach-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12"
"mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong px-sp-2 py-sp-1 text-fs-12 " +
(isPreview ? "bg-preview-tint text-preview" : "bg-surface")
}
> >
{leaf.sessionId ? ( {leaf.sessionId ? (
<PaneTitle sessionId={leaf.sessionId} session={session} /> <PaneTitle sessionId={leaf.sessionId} session={session} />
+2 -2
View File
@@ -15,7 +15,7 @@ export interface PaneTitleProps {
export function PaneTitle({ sessionId, session, className }: PaneTitleProps) { export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
if (!session) { if (!session) {
return ( return (
<span className={className ?? "at-title"} title={sessionId}> <span className={className ?? "attach-title"} title={sessionId}>
session <span className="sid">{sessionId.slice(0, 8)}</span> session <span className="sid">{sessionId.slice(0, 8)}</span>
</span> </span>
); );
@@ -27,7 +27,7 @@ export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
const icon = tool ? toolIconSvg(tool) : null; const icon = tool ? toolIconSvg(tool) : null;
return ( return (
<span className={className ?? "at-title"} title={`${name} · ${session.command} (${sessionId})`}> <span className={className ?? "attach-title"} title={`${name} · ${session.command} (${sessionId})`}>
<span className="at-name">{name}</span> <span className="at-name">{name}</span>
{showCommand && ( {showCommand && (
<span className="at-cmd"> <span className="at-cmd">
+2 -2
View File
@@ -1,4 +1,4 @@
// docs/SIDEBAR.md: a 5px bar pinned to the row's own left edge, square // docs/SIDEBAR.md: a 7px bar pinned to the row's own left edge, square
// corners, full card height. It paints for every card, Idle included, and it // corners, full card height. It paints for every card, Idle included, and it
// carries the accessible name of the state so status is never colour-only. // carries the accessible name of the state so status is never colour-only.
@@ -37,7 +37,7 @@ export function ActivityRail({ activity }: { activity: ActivityState | string |
const word = STATE_WORDS[slug] ?? STATE_WORDS.idle; const word = STATE_WORDS[slug] ?? STATE_WORDS.idle;
return ( return (
<span <span
className={`absolute left-0 top-0 bottom-0 w-[5px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`} className={`absolute left-0 top-0 bottom-0 w-[7px] ${RAIL_CLASS[slug] ?? RAIL_CLASS.idle}`}
data-activity={slug} data-activity={slug}
role="img" role="img"
title={word} title={word}
+12 -9
View File
@@ -44,14 +44,17 @@ export interface SessionCardProps {
onRenameCancel(): void; onRenameCancel(): void;
} }
/** The fill ladder from docs/SIDEBAR.md "Which row is filled", furthest down /** The fill ladder from docs/SIDEBAR.md "Which row is filled" — the CSS
* wins. Written as one ordered run so the precedence is the order it reads in. */ * cascade in styles.css carries the actual fills via `.is-active`, `.is-open`,
function rowFill(p: SessionCardProps): string { * `.is-previewing` (all inside `:where()` so source order is the precedence).
if (p.active) return "bg-accent-fill"; * We just apply the right class names here. */
if (p.previewing) return "bg-preview-tint"; function rowClasses(p: SessionCardProps): string {
if (p.open) return "bg-accent/[0.06]"; const cls: string[] = [];
if (p.highlighted) return "bg-preview-tint"; if (p.active) cls.push("is-active");
return ""; if (p.open) cls.push("is-open");
if (p.previewing) cls.push("is-previewing");
if (p.highlighted) cls.push("is-highlighted");
return cls.join(" ");
} }
export function SessionCard(props: SessionCardProps) { export function SessionCard(props: SessionCardProps) {
@@ -62,7 +65,7 @@ export function SessionCard(props: SessionCardProps) {
return ( return (
<a <a
className={`session-card tree-leaf no-underline ${rowFill(props)} ${ className={`session-card tree-leaf no-underline ${rowClasses(props)} ${
running ? "" : "opacity-60" running ? "" : "opacity-60"
}`} }`}
href={`#/attach/${encodeURIComponent(s.id)}`} href={`#/attach/${encodeURIComponent(s.id)}`}
@@ -323,6 +323,26 @@ describe("prompt and cancel", () => {
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up")); await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up"));
}); });
it("concatenates multiple queued messages into a single prompt", async () => {
const { socket } = mount();
snapshot(socket, [], busy);
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
const form = document.querySelector("form.structured-composer")!;
fireEvent.change(input, { target: { value: "first" } });
fireEvent.submit(form);
fireEvent.change(input, { target: { value: "second" } });
fireEvent.submit(form);
expect(sendStructuredPrompt).not.toHaveBeenCalled();
const queued = document.querySelectorAll(".st-row-queued");
expect(queued).toHaveLength(2);
socket.emit({ type: "reduced_state", state: idle });
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "first\n\nsecond"));
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
});
it("hands a failed send's draft back to the operator", async () => { it("hands a failed send's draft back to the operator", async () => {
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom")); vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
const { socket } = mount(); const { socket } = mount();
@@ -251,14 +251,16 @@ export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPanePro
[sessionId, socketGeneration, session], [sessionId, socketGeneration, session],
); );
// A queued message goes out as soon as the projected turn is idle — the // Queued messages are concatenated into a single prompt so the agent sees
// daemon answers a second prompt `409 turn_in_flight`, so the queue is // one user turn instead of N back-to-back turns. The daemon rejects a
// client-local (docs/STRUCTURED-VIEW.md). // second prompt while a turn is in flight (`409 turn_in_flight`), so the
// queue is client-local (docs/STRUCTURED-VIEW.md).
useEffect(() => { useEffect(() => {
if (active || promptPending.current || queue.length === 0) return; if (active || promptPending.current || queue.length === 0) return;
const [next, ...rest] = queue; const text = queue.map((q) => q.text).join("\n\n");
setQueue(rest); const files = queue.flatMap((q) => q.attachments);
dispatchPrompt(next.text, next.attachments); setQueue([]);
dispatchPrompt(text, files);
}, [active, queue, dispatchPrompt]); }, [active, queue, dispatchPrompt]);
const submit = (text: string, files: File[]) => { const submit = (text: string, files: File[]) => {
+46 -30
View File
@@ -115,8 +115,8 @@ describe("activity grouping", () => {
it("groups a turn's tools once there are two of them", () => { it("groups a turn's tools once there are two of them", () => {
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2")]; const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2")];
const groups = activityGroupsByTurn(rows, null, false); const groups = activityGroupsByTurn(rows, null, false);
expect([...groups.keys()]).toEqual(["turn:user:1"]); expect([...groups.keys()]).toEqual(["turn:user:1:0"]);
expect(groups.get("turn:user:1")).toMatchObject({ toolCount: 2 }); expect(groups.get("turn:user:1:0")).toMatchObject({ toolCount: 2 });
}); });
it("leaves a single tool call standalone", () => { it("leaves a single tool call standalone", () => {
@@ -124,7 +124,7 @@ describe("activity grouping", () => {
expect(activityGroupsByTurn(rows, null, false).size).toBe(0); expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
}); });
it("absorbs intermediate narration but keeps the final answer outside", () => { it("splits tools into separate runs when text appears between them", () => {
const rows = [ const rows = [
text("user:1", "user", "go"), text("user:1", "user", "go"),
tool("tool:1"), tool("tool:1"),
@@ -132,12 +132,26 @@ describe("activity grouping", () => {
tool("tool:2"), tool("tool:2"),
text("agent:2", "agent_text", "done"), text("agent:2", "agent_text", "done"),
]; ];
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!; // Each run has only one tool, so neither qualifies for grouping.
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]); expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
expect(group.toolCount).toBe(2);
}); });
it("keeps the newest thought outside while the turn is still live", () => { it("groups contiguous tools separated by text into distinct groups", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
tool("tool:2"),
text("agent:1", "agent_text", "working"),
tool("tool:3"),
tool("tool:4"),
];
const groups = activityGroupsByTurn(rows, null, false);
expect([...groups.keys()]).toEqual(["turn:user:1:0", "turn:user:1:1"]);
expect(groups.get("turn:user:1:0")!.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
expect(groups.get("turn:user:1:1")!.rows.map((r) => r.id)).toEqual(["tool:3", "tool:4"]);
});
it("keeps thoughts outside the group as standalone items", () => {
const rows = [ const rows = [
text("user:1", "user", "go"), text("user:1", "user", "go"),
tool("tool:1"), tool("tool:1"),
@@ -145,26 +159,8 @@ describe("activity grouping", () => {
text("thought:1", "agent_thought", "hmm"), text("thought:1", "agent_thought", "hmm"),
text("thought:2", "agent_thought", "aha"), text("thought:2", "agent_thought", "aha"),
]; ];
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!; const group = activityGroupsByTurn(rows, null, false).get("turn:user:1:0")!;
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]); expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
});
it("keeps the newest agent_text outside while the turn is still live", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
text("agent:1", "agent_text", "working on it"),
tool("tool:2"),
text("agent:2", "agent_text", "almost done"),
];
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
// "almost done" stays outside as visible narration while the turn is live
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
// Once ended, the final agent_text stays outside as the answer
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
}); });
it("tracks the last tool's label and argument for the collapsed header", () => { it("tracks the last tool's label and argument for the collapsed header", () => {
@@ -173,7 +169,7 @@ describe("activity grouping", () => {
row("tool:1", "tool_call", { title: "Read(src/main.ts)", status: "completed" }), row("tool:1", "tool_call", { title: "Read(src/main.ts)", status: "completed" }),
row("tool:2", "tool_call", { title: "Execute(git status)", kind: "execute", status: "completed" }), row("tool:2", "tool_call", { title: "Execute(git status)", kind: "execute", status: "completed" }),
]; ];
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!; const group = activityGroupsByTurn(rows, null, false).get("turn:user:1:0")!;
expect(group.lastToolLabel).toBe("Execute"); expect(group.lastToolLabel).toBe("Execute");
expect(group.lastToolArg).toBe("git status"); expect(group.lastToolArg).toBe("git status");
}); });
@@ -187,7 +183,7 @@ describe("activity grouping", () => {
row("approval:2", "approval"), row("approval:2", "approval"),
tool("tool:3"), tool("tool:3"),
]; ];
const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1")!; const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1:0")!;
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "approval:1", "tool:2", "tool:3"]); expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "approval:1", "tool:2", "tool:3"]);
}); });
}); });
@@ -198,11 +194,31 @@ describe("transcript render list", () => {
const items = transcriptItems(rows, null, false); const items = transcriptItems(rows, null, false);
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([ expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
"user:1", "user:1",
"turn:user:1", "turn:user:1:0",
"agent:1", "agent:1",
]); ]);
}); });
it("interleaves groups and text in timeline order", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
tool("tool:2"),
text("agent:1", "agent_text", "working"),
tool("tool:3"),
tool("tool:4"),
text("agent:2", "agent_text", "done"),
];
const items = transcriptItems(rows, null, false);
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
"user:1",
"turn:user:1:0",
"agent:1",
"turn:user:1:1",
"agent:2",
]);
});
it("omits plan rows, state-only notices and standalone resolved approvals", () => { it("omits plan rows, state-only notices and standalone resolved approvals", () => {
const rows = [ const rows = [
text("user:1", "user", "go"), text("user:1", "user", "go"),
+57 -77
View File
@@ -144,9 +144,10 @@ export function turnIsActive(state: ReducedState): boolean {
} }
export interface ActivityGroup { export interface ActivityGroup {
/** Header label count — tool rows only, never absorbed text. */ /** Header label count — tool rows only. */
toolCount: number; toolCount: number;
/** Tools plus absorbed intermediate text/approvals, in transcript order. */ /** Tool rows and resolved approvals, in transcript order. Text/thought
* rows stay outside the group as standalone transcript items. */
rows: TranscriptRow[]; rows: TranscriptRow[];
/** `splitToolTitle` of the last tool row — the collapsed header shows it. */ /** `splitToolTitle` of the last tool row — the collapsed header shows it. */
lastToolLabel: string; lastToolLabel: string;
@@ -154,87 +155,68 @@ export interface ActivityGroup {
} }
/** /**
* Group tool rows (and intermediate agent text / resolved approvals between * Group contiguous tool rows (and resolved approvals between them) into
* them) by turn. An agent_text/agent_thought row is "intermediate" when a tool * collapsible blocks. An `AgentText` or `AgentThought` row between tools
* row follows it in the same turn — it gets absorbed into the group so the * splits the run — each contiguous stretch of tools becomes its own group,
* collapsed block preserves chronological order. Resolved approval rows (not * so the timeline reads text → group → text → group rather than lumping
* the currently pending one) are absorbed the same way. The *last* agent text * every tool in a turn into one box.
* with no subsequent tool stays standalone (the prominent final answer).
* *
* While a turn is still live, the most recent text or thought row trailing the * A group must contain at least two tools to qualify; a single tool stays
* group also stays outside as visible narration — the operator always sees the * standalone. Returns groups keyed by `turn:<userId>:<seq>`.
* agent's latest reasoning without opening the group.
*
* Returns groups keyed by `turn:<userId>`.
*/ */
export function activityGroupsByTurn( export function activityGroupsByTurn(
rows: TranscriptRow[], rows: TranscriptRow[],
pendingApprovalId: string | null, pendingApprovalId: string | null,
turnActive: boolean, _turnActive: boolean,
): Map<string, ActivityGroup> { ): Map<string, ActivityGroup> {
// First: count tools per turn to know which turns qualify for grouping.
const toolCounts = new Map<string, number>();
let turnId = "before-first-message";
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
const gid = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
}
// Second: build groups with interleaved text for turns with 2+ tools.
const groups = new Map<string, ActivityGroup>(); const groups = new Map<string, ActivityGroup>();
turnId = "before-first-message"; let turnId = "before-first-message";
// Pending rows that might be intermediate (not yet confirmed by a following let runSeq = 0;
// tool). If no tool follows, they stay standalone. let runTools: TranscriptRow[] = [];
let pending: TranscriptRow[] = []; let runPending: TranscriptRow[] = [];
let currentGid = `turn:${turnId}`;
const flushRun = () => {
if (runTools.length >= 2) {
const gid = `turn:${turnId}:${runSeq}`;
const tool = (runTools[runTools.length - 1].data ?? {}) as { title?: string; name?: string | null; kind?: string };
const { label, arg } = splitToolTitle(tool.title, tool.name ?? undefined, tool.kind);
groups.set(gid, {
toolCount: runTools.length,
rows: [...runTools],
lastToolLabel: label,
lastToolArg: arg,
});
runSeq++;
}
runTools = [];
runPending = [];
};
for (const row of rows) { for (const row of rows) {
if (row.kind === "user") { if (row.kind === "user") {
flushRun();
turnId = row.id; turnId = row.id;
currentGid = `turn:${turnId}`; runSeq = 0;
pending = []; continue;
}
// Text and thought rows break the current tool run.
if (row.kind === "agent_text" || row.kind === "agent_thought") {
flushRun();
continue; continue;
} }
const count = toolCounts.get(currentGid) ?? 0;
if (count < 2) continue;
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId; const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
if (isIntermediateText || isResolvedApproval) { if (isResolvedApproval) {
pending.push(row); runPending.push(row);
continue; continue;
} }
if (row.kind === "tool_call" || row.kind === "tool_diff") { if (row.kind === "tool_call" || row.kind === "tool_diff") {
const group = groups.get(currentGid) ?? { toolCount: count, rows: [], lastToolLabel: "", lastToolArg: "" }; // Flush any pending resolved approvals into the run before this tool.
// Flush any pending rows — a tool follows, so they're intermediate. runTools.push(...runPending);
group.rows.push(...pending); runPending = [];
pending = []; runTools.push(row);
group.rows.push(row);
const tool = (row.data ?? {}) as { title?: string; name?: string | null; kind?: string };
const { label, arg } = splitToolTitle(tool.title, tool.name ?? undefined, tool.kind);
group.lastToolLabel = label;
group.lastToolArg = arg;
groups.set(currentGid, group);
} }
} }
// Trailing text/thought rows after the last tool in this turn. flushRun();
// While the turn is still live, the newest one stays outside as visible
// narration — the operator always sees the agent's latest reasoning.
// A final agent_text (the "answer") stays outside regardless.
const lastGroup = groups.get(currentGid);
if (lastGroup && pending.length > 0) {
const allThoughts = pending.every((row) => row.kind === "agent_thought");
if (allThoughts) {
// Pure thoughts: absorb all when done, keep the newest outside while live.
const absorb = turnActive ? pending.slice(0, -1) : pending;
lastGroup.rows.push(...absorb);
} else if (turnActive) {
// Mixed or agent_text trailing: keep the very last row outside while live,
// absorb the rest.
lastGroup.rows.push(...pending.slice(0, -1));
}
// When the turn ends with agent_text last, nothing extra is absorbed — the
// text stays outside as the final answer.
}
return groups; return groups;
} }
@@ -246,10 +228,10 @@ export type TranscriptItem =
| { type: "group"; id: string; group: ActivityGroup }; | { type: "group"; id: string; group: ActivityGroup };
/** /**
* The transcript in render order: rows, with each turn's tool activity folded * The transcript in render order: rows, with each contiguous tool stretch
* into a single group item where one exists. Resolved approvals not absorbed * folded into a single group item where one exists. Resolved approvals not
* into a group are dropped — the tool row itself already identifies the * absorbed into a group are dropped — the tool row itself already identifies
* action. `plan` rows and state-only notices are dropped too (both clients * the action. `plan` rows and state-only notices are dropped too (both clients
* omit `Plan` from the transcript). A turn footer only survives as the final * omit `Plan` from the transcript). A turn footer only survives as the final
* item, so old footers stop reading like transcript content. * item, so old footers stop reading like transcript content.
*/ */
@@ -259,21 +241,19 @@ export function transcriptItems(
turnActive: boolean, turnActive: boolean,
): TranscriptItem[] { ): TranscriptItem[] {
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive); const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
const grouped = new Set<string>(); // Reverse index: row id → group id that contains it.
for (const group of groups.values()) for (const row of group.rows) grouped.add(row.id); const rowToGroup = new Map<string, string>();
for (const [groupId, group] of groups) for (const r of group.rows) rowToGroup.set(r.id, groupId);
const items: TranscriptItem[] = []; const items: TranscriptItem[] = [];
let turnId = "before-first-message";
const emitted = new Set<string>(); const emitted = new Set<string>();
for (const row of rows) { for (const row of rows) {
if (row.kind === "user") turnId = row.id; const groupId = rowToGroup.get(row.id);
const groupId = `turn:${turnId}`; if (groupId !== undefined) {
const group = groups.get(groupId);
if (group && grouped.has(row.id)) {
// The group renders once, at its first member's position. // The group renders once, at its first member's position.
if (!emitted.has(groupId)) { if (!emitted.has(groupId)) {
emitted.add(groupId); emitted.add(groupId);
items.push({ type: "group", id: groupId, group }); items.push({ type: "group", id: groupId, group: groups.get(groupId)! });
} }
continue; continue;
} }
@@ -196,12 +196,24 @@ export function useStructuredSession(
connect(); connect();
}; };
// Mobile browsers suspend timers and silently kill WebSockets in the
// background. Kick a fresh connect when the page becomes visible again.
const onVisibility = () => {
if (document.visibilityState !== "visible" || disposed) return;
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return;
cancelTimer();
setStatus("reconnecting");
connect();
};
document.addEventListener("visibilitychange", onVisibility);
setStatus("connecting"); setStatus("connecting");
connect(); connect();
return () => { return () => {
disposed = true; disposed = true;
reconnectRef.current = () => {}; reconnectRef.current = () => {};
document.removeEventListener("visibilitychange", onVisibility);
cancelTimer(); cancelTimer();
closeSocket(); closeSocket();
}; };
@@ -302,6 +302,24 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
ta?.addEventListener("focus", onTaFocus); ta?.addEventListener("focus", onTaFocus);
ta?.addEventListener("blur", onTaBlur); ta?.addEventListener("blur", onTaBlur);
// Mobile browsers (Android especially) suspend timers and silently kill
// WebSockets when the app goes to background. The backoff timer set by
// onclose is frozen too, so recovery stalls until visibility returns. Kick
// a fresh connect immediately when the page becomes visible again.
const onVisibility = () => {
if (document.visibilityState !== "visible" || disposed) return;
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) return;
// Cancel any pending backoff — we're reconnecting right now.
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnectMs = INITIAL_RECONNECT_MS;
onStatusChange?.("reconnecting");
connect();
};
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null; const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize); visual?.addEventListener("resize", onResize);
@@ -326,6 +344,7 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
reconnectTimer = null; reconnectTimer = null;
} }
ro.disconnect(); ro.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("resize", onResize); window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize); visual?.removeEventListener("resize", onResize);
ta?.removeEventListener("focus", onTaFocus); ta?.removeEventListener("focus", onTaFocus);
+1 -1
View File
@@ -1623,7 +1623,7 @@ input.leaf-rename:focus {
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 5px; width: 7px;
background: currentColor; background: currentColor;
} }