Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd5bbcfcb1 | ||
|
|
cbbf526fc4 | ||
|
|
9f5b71d563 | ||
|
|
de603ccd20 | ||
|
|
c6af78e9dd | ||
|
|
7ab503e4f5 | ||
|
|
eaf11a7696 | ||
|
|
4738233580 | ||
|
|
cb25320ea0 | ||
|
|
ff52dd9812 | ||
|
|
f3a5cad789 | ||
|
|
fffd2a55f3 | ||
|
|
563a35d2ec | ||
|
|
9dead5652e | ||
|
|
cd2184b128 | ||
|
|
1b2e5f5f05 |
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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[]) => {
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user