Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa846000e7 |
@@ -252,6 +252,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,7 +307,11 @@ async fn report_activity(
|
|||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<ReportActivityRequest>,
|
Json(body): Json<ReportActivityRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match s.manager.report_activity(id, body.state, body.detail).await {
|
match s
|
||||||
|
.manager
|
||||||
|
.report_activity(id, body.state, body.detail, body.session_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let info = e.to_error_info();
|
let info = e.to_error_info();
|
||||||
|
|||||||
@@ -478,6 +478,7 @@ impl SessionManager {
|
|||||||
// fresh tree has nothing in the way anyway.
|
// fresh tree has nothing in the way anyway.
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||||
@@ -683,6 +684,7 @@ impl SessionManager {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||||
@@ -881,11 +883,18 @@ impl SessionManager {
|
|||||||
/// A report that changes the effective state restarts `activity_since`
|
/// A report that changes the effective state restarts `activity_since`
|
||||||
/// (#104's clock); re-reporting an unchanged state only extends the TTL
|
/// (#104's clock); re-reporting an unchanged state only extends the TTL
|
||||||
/// and stays silent on the event stream at steady state.
|
/// and stays silent on the event stream at steady state.
|
||||||
|
///
|
||||||
|
/// `agent_session_id` (#161) is the agent's own conversation id, parsed
|
||||||
|
/// by the caller from the hook payload's `session_id` field. It is
|
||||||
|
/// written once — the first report that carries one wins — and the
|
||||||
|
/// sidecar is only rewritten on that transition, never on every report,
|
||||||
|
/// since an agent's session id never changes mid-session.
|
||||||
pub async fn report_activity(
|
pub async fn report_activity(
|
||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
state: ActivityState,
|
state: ActivityState,
|
||||||
detail: Option<String>,
|
detail: Option<String>,
|
||||||
|
agent_session_id: Option<String>,
|
||||||
) -> Result<(), SessionError> {
|
) -> Result<(), SessionError> {
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut sessions = self.inner.sessions.lock().await;
|
let mut sessions = self.inner.sessions.lock().await;
|
||||||
@@ -901,7 +910,16 @@ impl SessionManager {
|
|||||||
state,
|
state,
|
||||||
self.inner.report_ttl,
|
self.inner.report_ttl,
|
||||||
));
|
));
|
||||||
changed.then(|| rec.session.clone())
|
let newly_identified =
|
||||||
|
rec.session.agent_session_id.is_none() && agent_session_id.is_some();
|
||||||
|
if newly_identified {
|
||||||
|
rec.session.agent_session_id = agent_session_id;
|
||||||
|
self.inner
|
||||||
|
.sidecar
|
||||||
|
.write(&rec.session)
|
||||||
|
.map_err(SessionError::Sidecar)?;
|
||||||
|
}
|
||||||
|
(changed || newly_identified).then(|| rec.session.clone())
|
||||||
};
|
};
|
||||||
if let Some(session) = updated {
|
if let Some(session) = updated {
|
||||||
let _ = self
|
let _ = self
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
struct Fixture {
|
struct Fixture {
|
||||||
manager: SessionManager,
|
manager: SessionManager,
|
||||||
|
sidecar: SidecarStore,
|
||||||
_tmux: TmuxFixture,
|
_tmux: TmuxFixture,
|
||||||
_state_dir: TempDir,
|
_state_dir: TempDir,
|
||||||
}
|
}
|
||||||
@@ -25,15 +26,17 @@ struct Fixture {
|
|||||||
async fn fixture(ttl: Duration) -> Fixture {
|
async fn fixture(ttl: Duration) -> Fixture {
|
||||||
let tmux = TmuxFixture::new();
|
let tmux = TmuxFixture::new();
|
||||||
let state_dir = TempDir::new().unwrap();
|
let state_dir = TempDir::new().unwrap();
|
||||||
|
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
||||||
let manager = SessionManager::new(
|
let manager = SessionManager::new(
|
||||||
Arc::new(tmux.tmux.clone()),
|
Arc::new(tmux.tmux.clone()),
|
||||||
SidecarStore::new(state_dir.path().join("sessions")),
|
sidecar.clone(),
|
||||||
WorktreeManager::new(None),
|
WorktreeManager::new(None),
|
||||||
state_dir.path().join("fifos"),
|
state_dir.path().join("fifos"),
|
||||||
)
|
)
|
||||||
.with_activity_report_ttl(ttl);
|
.with_activity_report_ttl(ttl);
|
||||||
Fixture {
|
Fixture {
|
||||||
manager,
|
manager,
|
||||||
|
sidecar,
|
||||||
_tmux: tmux,
|
_tmux: tmux,
|
||||||
_state_dir: state_dir,
|
_state_dir: state_dir,
|
||||||
}
|
}
|
||||||
@@ -67,7 +70,7 @@ async fn report_on_unknown_session_is_not_found() {
|
|||||||
let fx = fixture(Duration::from_secs(300)).await;
|
let fx = fixture(Duration::from_secs(300)).await;
|
||||||
let err = fx
|
let err = fx
|
||||||
.manager
|
.manager
|
||||||
.report_activity(Uuid::new_v4(), ActivityState::Idle, None)
|
.report_activity(Uuid::new_v4(), ActivityState::Idle, None, None)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
||||||
@@ -83,6 +86,7 @@ async fn report_sets_error_state_and_detail() {
|
|||||||
id,
|
id,
|
||||||
ActivityState::Error,
|
ActivityState::Error,
|
||||||
Some("cargo build failed: link error".into()),
|
Some("cargo build failed: link error".into()),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -101,7 +105,7 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
|
|||||||
let id = spawn_sleeping(&fx.manager).await;
|
let id = spawn_sleeping(&fx.manager).await;
|
||||||
|
|
||||||
fx.manager
|
fx.manager
|
||||||
.report_activity(id, ActivityState::Error, Some("first failure".into()))
|
.report_activity(id, ActivityState::Error, Some("first failure".into()), None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fx.manager
|
fx.manager
|
||||||
@@ -109,6 +113,7 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
|
|||||||
id,
|
id,
|
||||||
ActivityState::AwaitingInput,
|
ActivityState::AwaitingInput,
|
||||||
Some("needs approval".into()),
|
Some("needs approval".into()),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -125,7 +130,7 @@ async fn detail_is_capped_server_side() {
|
|||||||
|
|
||||||
let long = "x".repeat(10_000);
|
let long = "x".repeat(10_000);
|
||||||
fx.manager
|
fx.manager
|
||||||
.report_activity(id, ActivityState::Error, Some(long))
|
.report_activity(id, ActivityState::Error, Some(long), None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -144,7 +149,12 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
|
|||||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||||
|
|
||||||
fx.manager
|
fx.manager
|
||||||
.report_activity(id, ActivityState::Error, Some("agent died mid-run".into()))
|
.report_activity(
|
||||||
|
id,
|
||||||
|
ActivityState::Error,
|
||||||
|
Some("agent died mid-run".into()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -162,3 +172,55 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_reported_session_id_is_recorded_on_the_session() {
|
||||||
|
let fx = fixture(Duration::from_secs(300)).await;
|
||||||
|
let id = spawn_sleeping(&fx.manager).await;
|
||||||
|
|
||||||
|
fx.manager
|
||||||
|
.report_activity(id, ActivityState::Active, None, Some("conv-abc123".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let s = observed(&fx.manager, id).await;
|
||||||
|
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #161: a session id does not change mid-session, so once set it is never
|
||||||
|
/// overwritten by a later report — even one that names a different id.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_reported_session_id_is_written_once_and_never_replaced() {
|
||||||
|
let fx = fixture(Duration::from_secs(300)).await;
|
||||||
|
let id = spawn_sleeping(&fx.manager).await;
|
||||||
|
|
||||||
|
fx.manager
|
||||||
|
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fx.manager
|
||||||
|
.report_activity(id, ActivityState::Active, None, Some("conv-second".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let s = observed(&fx.manager, id).await;
|
||||||
|
assert_eq!(s.agent_session_id.as_deref(), Some("conv-first"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_reported_session_id_survives_recovery_from_the_sidecar() {
|
||||||
|
let fx = fixture(Duration::from_secs(300)).await;
|
||||||
|
let id = spawn_sleeping(&fx.manager).await;
|
||||||
|
|
||||||
|
fx.manager
|
||||||
|
.report_activity(id, ActivityState::Active, None, Some("conv-abc123".into()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let stored = fx
|
||||||
|
.sidecar
|
||||||
|
.read(id)
|
||||||
|
.expect("sidecar read")
|
||||||
|
.expect("sidecar present");
|
||||||
|
assert_eq!(stored.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -526,6 +526,33 @@ async fn post_activity_reports_error_with_detail() {
|
|||||||
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
|
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #161: a reported `session_id` lands on the session as `agent_session_id`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn post_activity_records_the_reported_session_id() {
|
||||||
|
let h = spawn("naps-laptop").await;
|
||||||
|
let id = spawn_via_manager(&h).await;
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"state": "Active",
|
||||||
|
"session_id": "conv-abc123"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let s = sessions.iter().find(|s| s.id == id).unwrap();
|
||||||
|
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn post_activity_to_unknown_session_returns_404() {
|
async fn post_activity_to_unknown_session_returns_404() {
|
||||||
let h = spawn("naps-laptop").await;
|
let h = spawn("naps-laptop").await;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ fn sample(id: Uuid) -> Session {
|
|||||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: Some("conv-abc123".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,15 +84,23 @@ if [ "$state" = "Prompt" ]; then
|
|||||||
>/dev/null 2>&1 || true
|
>/dev/null 2>&1 || true
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
body="{\"state\":\"$state\"}"
|
fields="\"state\":\"$state\""
|
||||||
# StopFailure payloads carry the outcome in "error". Only a plain enum
|
# StopFailure payloads carry the outcome in "error". Only a plain enum
|
||||||
# spelling is reported as the detail — anything else (quotes, backslashes,
|
# spelling is reported as the detail — anything else (quotes, backslashes,
|
||||||
# prose) would risk breaking the JSON body, and the spelling already names
|
# prose) would risk breaking the JSON body, and the spelling already names
|
||||||
# the failure.
|
# the failure.
|
||||||
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
|
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
|
||||||
if [ -n "$detail" ]; then
|
if [ -n "$detail" ]; then
|
||||||
body="{\"state\":\"$state\",\"detail\":\"$detail\"}"
|
fields="$fields,\"detail\":\"$detail\""
|
||||||
fi
|
fi
|
||||||
|
# Every Claude Code hook payload carries its own conversation id (#161).
|
||||||
|
# Constrained to a safe charset for the same reason as "detail" above: it is
|
||||||
|
# spliced into the JSON body unescaped.
|
||||||
|
session_id=$(printf '%s' "$payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p')
|
||||||
|
if [ -n "$session_id" ]; then
|
||||||
|
fields="$fields,\"session_id\":\"$session_id\""
|
||||||
|
fi
|
||||||
|
body="{$fields}"
|
||||||
curl -fsS --connect-timeout 1 --max-time 2 \
|
curl -fsS --connect-timeout 1 --max-time 2 \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "$body" \
|
-d "$body" \
|
||||||
|
|||||||
@@ -128,6 +128,41 @@ fn script_reports_the_error_spelling_as_detail() {
|
|||||||
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
|
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #161: the payload's own `session_id` — the agent's conversation id — is
|
||||||
|
/// parsed out and forwarded in the activity body, so the daemon can persist
|
||||||
|
/// it on the session.
|
||||||
|
#[test]
|
||||||
|
fn script_forwards_the_payloads_session_id_in_the_activity_body() {
|
||||||
|
let request = capture_request(|url| {
|
||||||
|
sh(
|
||||||
|
HOOK_SCRIPT,
|
||||||
|
&["Active"],
|
||||||
|
&owned_envs(&session_envs(&url)),
|
||||||
|
br#"{"session_id":"conv-abc123","hook_event_name":"PreToolUse"}"#,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(request.contains(r#""state":"Active""#), "{request}");
|
||||||
|
assert!(
|
||||||
|
request.contains(r#""session_id":"conv-abc123""#),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A payload without a `session_id` (or one with an unexpected shape) must
|
||||||
|
/// never produce a broken body — no `session_id` field is posted.
|
||||||
|
#[test]
|
||||||
|
fn script_posts_no_session_id_when_the_payload_has_none() {
|
||||||
|
let request = capture_request(|url| {
|
||||||
|
sh(
|
||||||
|
HOOK_SCRIPT,
|
||||||
|
&["AwaitingInput"],
|
||||||
|
&owned_envs(&session_envs(&url)),
|
||||||
|
br#"{"hook_event_name":"Stop","stop_hook_active":false}"#,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(!request.contains("session_id"), "{request}");
|
||||||
|
}
|
||||||
|
|
||||||
/// An `error` value that is not a plain enum spelling (or a payload without
|
/// An `error` value that is not a plain enum spelling (or a payload without
|
||||||
/// one at all) must never produce a broken body — no detail is posted.
|
/// one at all) must never produce a broken body — no detail is posted.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ pub struct Session {
|
|||||||
/// sorts a never-focused session after every session that has one.
|
/// sorts a never-focused session after every session that has one.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_focused_at: Option<DateTime<Utc>>,
|
pub last_focused_at: Option<DateTime<Utc>>,
|
||||||
|
/// The conversation id the agent running in this session reported for
|
||||||
|
/// itself (#161), parsed from a hook's `session_id` field. Written once,
|
||||||
|
/// from the first activity report that carries one, and left alone after
|
||||||
|
/// — an agent's own session id does not change mid-session. `None` for
|
||||||
|
/// sessions that never run an agent (plain shells) or predate the field.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub agent_session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
||||||
@@ -184,6 +191,11 @@ pub struct ReportActivityRequest {
|
|||||||
/// The daemon caps its length server-side.
|
/// The daemon caps its length server-side.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub detail: Option<String>,
|
pub detail: Option<String>,
|
||||||
|
/// The agent's own conversation id (#161), forwarded from the hook
|
||||||
|
/// payload's `session_id` field. `None` for hook events that don't carry
|
||||||
|
/// one, or hooks predating this field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
|
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
|
||||||
@@ -862,6 +874,7 @@ mod tests {
|
|||||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,6 +1044,7 @@ mod tests {
|
|||||||
let full = ReportActivityRequest {
|
let full = ReportActivityRequest {
|
||||||
state: ActivityState::AwaitingInput,
|
state: ActivityState::AwaitingInput,
|
||||||
detail: Some("waiting on permission".into()),
|
detail: Some("waiting on permission".into()),
|
||||||
|
session_id: Some("conv-abc123".into()),
|
||||||
};
|
};
|
||||||
assert_eq!(full, round_trip(&full));
|
assert_eq!(full, round_trip(&full));
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1898,6 +1898,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
});
|
});
|
||||||
@@ -2079,6 +2080,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
@@ -2106,6 +2108,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3214,6 +3217,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: focused,
|
last_focused_at: focused,
|
||||||
|
agent_session_id: None,
|
||||||
};
|
};
|
||||||
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 {
|
||||||
@@ -3492,6 +3496,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::KillHighlighted);
|
app.dispatch(Action::KillHighlighted);
|
||||||
@@ -3527,6 +3532,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -809,6 +809,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1266,6 +1267,7 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
agent_session_id: None,
|
||||||
};
|
};
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ Web client (`--preview` and friends in `web/src/styles.css`):
|
|||||||
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
||||||
| A throwaway tab | Dashed border, italic label. |
|
| A throwaway tab | Dashed border, italic label. |
|
||||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
|
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
|
||||||
| The sidebar row of the session the operator picked | Keeps its accent fill for the whole preview. A preview never moves the accent; only opening another session does. |
|
|
||||||
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
||||||
|
|
||||||
Rows say it with fill, not with an edge — see the tier ladder in
|
Rows say it with fill, not with an edge — see the tier ladder in
|
||||||
|
|||||||
+3
-17
@@ -215,20 +215,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
return fl?.sessionId ?? null;
|
return fl?.sessionId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The session the operator actually picked — what `focusedLeafSession()`
|
|
||||||
* would return with the preview undone. A preview moves the active tab and
|
|
||||||
* its focus, but it is a view: the accent stays on the row the operator
|
|
||||||
* chose until they choose another (docs/PREVIEW.md). */
|
|
||||||
function realFocusedSession(): string | null {
|
|
||||||
const p = preview;
|
|
||||||
if (!p) return focusedLeafSession();
|
|
||||||
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
|
|
||||||
const leafId =
|
|
||||||
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
|
|
||||||
const fl = leafId ? findLeaf(tab.root, leafId) : null;
|
|
||||||
return fl?.sessionId ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update focus styling + pool focus + sidebar highlights without rebuilding
|
/** Update focus styling + pool focus + sidebar highlights without rebuilding
|
||||||
* the DOM (cheap path for click-to-focus). */
|
* the DOM (cheap path for click-to-focus). */
|
||||||
function syncFocus(): void {
|
function syncFocus(): void {
|
||||||
@@ -247,7 +233,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
if (!preview) pool.setFocus(focusedLeafSession());
|
if (!preview) pool.setFocus(focusedLeafSession());
|
||||||
sidebar.setHighlights({
|
sidebar.setHighlights({
|
||||||
open: realOpenSessions(),
|
open: realOpenSessions(),
|
||||||
focused: realFocusedSession(),
|
focused: focusedLeafSession(),
|
||||||
});
|
});
|
||||||
// The row the preview came from wears the same colour, so the sidebar and
|
// The row the preview came from wears the same colour, so the sidebar and
|
||||||
// the pane read as one thing rather than two unrelated highlights.
|
// the pane read as one thing rather than two unrelated highlights.
|
||||||
@@ -622,7 +608,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
{ focus: !preview && !quietRender },
|
{ focus: !preview && !quietRender },
|
||||||
);
|
);
|
||||||
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||||
sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() });
|
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
|
||||||
sidebar.setPreview(preview?.sessionId ?? null);
|
sidebar.setPreview(preview?.sessionId ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,7 +817,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
openSessions: realOpenSessions,
|
openSessions: realOpenSessions,
|
||||||
beginSessionDrag,
|
beginSessionDrag,
|
||||||
render,
|
render,
|
||||||
focusedSessionId: realFocusedSession,
|
focusedSessionId: focusedLeafSession,
|
||||||
previewSession,
|
previewSession,
|
||||||
previewedSessionId: () => preview?.sessionId ?? null,
|
previewedSessionId: () => preview?.sessionId ?? null,
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-33
@@ -47,20 +47,12 @@ function fakePool(): Spy {
|
|||||||
|
|
||||||
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
||||||
const previewRow: { id: string | null } = { id: null };
|
const previewRow: { id: string | null } = { id: null };
|
||||||
/** Last highlights the controller pushed to `SidebarHandle.setHighlights`. */
|
|
||||||
const rowHighlights: { open: Set<string>; focused: string | null } = {
|
|
||||||
open: new Set(),
|
|
||||||
focused: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function fakeSidebar(): SidebarHandle {
|
function fakeSidebar(): SidebarHandle {
|
||||||
return {
|
return {
|
||||||
el: document.createElement("aside"),
|
el: document.createElement("aside"),
|
||||||
setActive() {},
|
setActive() {},
|
||||||
setHighlights(h) {
|
setHighlights() {},
|
||||||
rowHighlights.open = h.open;
|
|
||||||
rowHighlights.focused = h.focused;
|
|
||||||
},
|
|
||||||
setPreview(id: string | null) {
|
setPreview(id: string | null) {
|
||||||
previewRow.id = id;
|
previewRow.id = id;
|
||||||
},
|
},
|
||||||
@@ -103,8 +95,6 @@ describe("sidebar preview", () => {
|
|||||||
stubFetch();
|
stubFetch();
|
||||||
document.body.replaceChildren();
|
document.body.replaceChildren();
|
||||||
previewRow.id = null;
|
previewRow.id = null;
|
||||||
rowHighlights.open = new Set();
|
|
||||||
rowHighlights.focused = null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -144,28 +134,6 @@ describe("sidebar preview", () => {
|
|||||||
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
|
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves the accent on the session the pane actually holds", () => {
|
|
||||||
const layout = twoTabs();
|
|
||||||
const spy = fakePool();
|
|
||||||
const mv = makeMv(layout, spy.pool);
|
|
||||||
expect(rowHighlights.focused).toBe("s2");
|
|
||||||
|
|
||||||
// Walking past other rows never moves the accent off what the operator
|
|
||||||
// picked: only a real open does (docs/PREVIEW.md).
|
|
||||||
mv.previewSession("s1");
|
|
||||||
expect(rowHighlights.focused).toBe("s2");
|
|
||||||
expect(mv.focusedSessionId()).toBe("s2");
|
|
||||||
|
|
||||||
mv.previewSession("s9");
|
|
||||||
expect(rowHighlights.focused).toBe("s2");
|
|
||||||
|
|
||||||
mv.previewSession(null);
|
|
||||||
expect(rowHighlights.focused).toBe("s2");
|
|
||||||
|
|
||||||
mv.openSession("s1");
|
|
||||||
expect(rowHighlights.focused).toBe("s1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
||||||
const layout = twoTabs();
|
const layout = twoTabs();
|
||||||
const before = serializeLayout(layout);
|
const before = serializeLayout(layout);
|
||||||
|
|||||||
Reference in New Issue
Block a user