Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6471da2624 | ||
|
|
2a43c76176 | ||
|
|
54d84e3eaa | ||
|
|
41c498401b | ||
|
|
6ca347b657 | ||
|
|
f60587a6d6 | ||
|
|
43946c3c46 | ||
|
|
c24a2133d4 | ||
|
|
75ccfe10d2 |
@@ -25,13 +25,25 @@ pub const HOOK_MARKER: &str = "maestro-claude-hook";
|
||||
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
|
||||
|
||||
/// Claude Code hook event → `ActivityState` spelling posted to the daemon.
|
||||
pub const HOOK_EVENTS: [(&str, &str); 3] = [
|
||||
// Tool finished: the agent is demonstrably producing work.
|
||||
///
|
||||
/// `PreToolUse` is what makes a session `Active` *during* a long tool call —
|
||||
/// `PostToolUse` alone only fires once it ends (#114). `StopFailure` is
|
||||
/// Claude Code's own name for "the turn ended on an API error"; there is no
|
||||
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
|
||||
/// `Error`.
|
||||
pub const HOOK_EVENTS: [(&str, &str); 5] = [
|
||||
// Tool starting: the agent is demonstrably producing work, even if the
|
||||
// tool itself runs for minutes.
|
||||
("PreToolUse", "Active"),
|
||||
// Tool finished successfully.
|
||||
("PostToolUse", "Active"),
|
||||
// Permission / idle notices: Claude is blocked on the user.
|
||||
("Notification", "AwaitingInput"),
|
||||
// Turn ended: waiting on the user again.
|
||||
("Stop", "AwaitingInput"),
|
||||
// Turn ended on an API error; the payload's `error` field is forwarded
|
||||
// as the report's `detail` (#114).
|
||||
("StopFailure", "Error"),
|
||||
];
|
||||
|
||||
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
|
||||
@@ -39,17 +51,27 @@ pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
||||
# Installed by maestro (#106): report Claude Code activity to its daemon.
|
||||
# Exits silently unless running inside a session maestro spawned (#103 env).
|
||||
# Event -> ActivityState mapping:
|
||||
# PostToolUse -> Active (tool finished; agent is working)
|
||||
# PreToolUse -> Active (tool started; agent is working)
|
||||
# PostToolUse -> Active (tool finished)
|
||||
# Notification -> AwaitingInput (permission / idle notice)
|
||||
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
||||
# StopFailure -> Error (turn ended on an API error)
|
||||
state="$1"
|
||||
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
|
||||
[ -n "$MAESTRO_URL" ] || exit 0
|
||||
# Drain the hook payload so the parent never sees a broken pipe.
|
||||
cat >/dev/null 2>&1
|
||||
payload=$(cat 2>/dev/null) || true
|
||||
body="{\"state\":\"$state\"}"
|
||||
# StopFailure payloads carry the outcome in "error". Only a plain enum
|
||||
# spelling is reported as the detail — anything else (quotes, backslashes,
|
||||
# prose) would risk breaking the JSON body, and the spelling already names
|
||||
# the failure.
|
||||
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
|
||||
if [ -n "$detail" ]; then
|
||||
body="{\"state\":\"$state\",\"detail\":\"$detail\"}"
|
||||
fi
|
||||
curl -fsS --connect-timeout 1 --max-time 2 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"state\":\"$state\"}" \
|
||||
-d "$body" \
|
||||
"$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity" \
|
||||
>/dev/null 2>&1 || true
|
||||
exit 0
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::process::{Command, Stdio};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)]) -> std::process::Output {
|
||||
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
// `sh -c CMD NAME ARGS…`: NAME fills $0, so the real arguments start at $1.
|
||||
let mut argv = vec!["-c", script, HOOK_MARKER];
|
||||
@@ -26,21 +26,48 @@ fn sh(script: &str, args: &[&str], envs: &[(&str, &str)]) -> std::process::Outpu
|
||||
.spawn()
|
||||
.expect("spawn /bin/sh");
|
||||
// Feed a Claude-Code-style payload on stdin; the script must drain it.
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(br#"{"session_id":"x"}"#)
|
||||
.ok();
|
||||
child.stdin.take().unwrap().write_all(payload).ok();
|
||||
child.wait_with_output().expect("wait")
|
||||
}
|
||||
|
||||
const SESSION: &str = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
|
||||
/// Accept exactly one HTTP request on a throwaway listener, answer 204, and
|
||||
/// hand back the raw request text. Runs `run` with the base URL once the
|
||||
/// acceptor thread is in place.
|
||||
fn capture_request(run: impl FnOnce(String) -> std::process::Output) -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut sock, _) = listener.accept().unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = sock.read(&mut buf).unwrap();
|
||||
sock.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&buf[..n]).to_string()
|
||||
});
|
||||
let out = run(format!("http://{addr}"));
|
||||
assert!(out.status.success(), "{:?}", out.stderr);
|
||||
handle.join().unwrap()
|
||||
}
|
||||
|
||||
fn session_envs(url: &str) -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
("MAESTRO_URL", url.to_string()),
|
||||
("MAESTRO_SESSION_ID", SESSION.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> {
|
||||
envs.iter().map(|(k, v)| (*k, v.as_str())).collect()
|
||||
}
|
||||
|
||||
/// The hook must be invisible outside maestro: no env vars (#103 unset) ⇒
|
||||
/// exit 0, no output, stdin drained.
|
||||
#[test]
|
||||
fn script_exits_silently_without_maestro_env() {
|
||||
for (event, _) in HOOK_EVENTS {
|
||||
let out = sh(HOOK_SCRIPT, &[event], &[]);
|
||||
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
|
||||
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
|
||||
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
|
||||
assert!(out.stderr.is_empty(), "{event:?}: {:?}", out.stderr);
|
||||
@@ -51,46 +78,82 @@ fn script_exits_silently_without_maestro_env() {
|
||||
/// activity endpoint and still exits 0.
|
||||
#[test]
|
||||
fn script_posts_mapped_state_to_the_activity_endpoint() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = move || {
|
||||
let (mut sock, _) = listener.accept().unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = sock.read(&mut buf).unwrap();
|
||||
let raw = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
sock.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
||||
.unwrap();
|
||||
raw
|
||||
};
|
||||
let handle = std::thread::spawn(server);
|
||||
|
||||
let url = format!("http://{addr}");
|
||||
let session = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
// The installer bakes the mapped state into the command's argv; the
|
||||
// script relays whatever it is given.
|
||||
let out = sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Active"],
|
||||
&[("MAESTRO_URL", &url), ("MAESTRO_SESSION_ID", session)],
|
||||
);
|
||||
assert!(out.status.success());
|
||||
|
||||
let request = handle.join().unwrap();
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Active"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"session_id":"x"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.starts_with("POST "), "{request}");
|
||||
assert!(
|
||||
request.contains(&format!("/api/sessions/{session}/activity")),
|
||||
request.contains(&format!("/api/sessions/{SESSION}/activity")),
|
||||
"{request}"
|
||||
);
|
||||
assert!(request.contains(r#""state":"Active""#), "{request}");
|
||||
}
|
||||
|
||||
/// Every event maps to the state the issue specifies.
|
||||
/// Every event maps to the state the contract specifies.
|
||||
#[test]
|
||||
fn event_state_mapping_matches_the_contract() {
|
||||
let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect();
|
||||
assert_eq!(map["PreToolUse"], "Active");
|
||||
assert_eq!(map["PostToolUse"], "Active");
|
||||
assert_eq!(map["Stop"], "AwaitingInput");
|
||||
assert_eq!(map["Notification"], "AwaitingInput");
|
||||
assert_eq!(map["Stop"], "AwaitingInput");
|
||||
assert_eq!(map["StopFailure"], "Error");
|
||||
}
|
||||
|
||||
/// A `StopFailure` payload names the API error that ended the turn; the
|
||||
/// script reports its `error` spelling as the report's `detail` so a card
|
||||
/// can show why (#114).
|
||||
#[test]
|
||||
fn script_reports_the_error_spelling_as_detail() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Error"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"session_id":"abc","hook_event_name":"StopFailure","error":"rate_limit","error_details":"429 Too Many Requests"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"Error""#), "{request}");
|
||||
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn script_posts_no_detail_for_an_unexpected_error_value() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Error"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"hook_event_name":"StopFailure","error":"429 \"Too Many\" \\ requests"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"Error""#), "{request}");
|
||||
assert!(!request.contains("detail"), "{request}");
|
||||
}
|
||||
|
||||
/// Events whose payload carries no error fields post a bare body — never an
|
||||
/// empty or stale detail.
|
||||
#[test]
|
||||
fn script_posts_no_detail_for_events_without_an_error() {
|
||||
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(r#""state":"AwaitingInput""#), "{request}");
|
||||
assert!(!request.contains("detail"), "{request}");
|
||||
}
|
||||
|
||||
/// A dead or unreachable daemon must never fail — or slow down — a session.
|
||||
@@ -104,6 +167,7 @@ fn script_ignores_daemon_failure() {
|
||||
("MAESTRO_URL", "http://127.0.0.1:1"),
|
||||
("MAESTRO_SESSION_ID", "00000000-0000-0000-0000-000000000000"),
|
||||
],
|
||||
br#"{"session_id":"x"}"#,
|
||||
);
|
||||
assert!(out.status.success());
|
||||
}
|
||||
@@ -138,9 +202,10 @@ fn fresh_install_registers_every_event() {
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings_path(home));
|
||||
assert_eq!(cmds.len(), 3);
|
||||
assert_eq!(cmds.len(), HOOK_EVENTS.len());
|
||||
let joined = cmds.join("\n");
|
||||
assert!(joined.contains("Active"));
|
||||
assert_eq!(joined.matches("Active").count(), 2);
|
||||
assert!(joined.contains("Error"));
|
||||
assert_eq!(joined.matches("AwaitingInput").count(), 2);
|
||||
for cmd in &cmds {
|
||||
assert!(cmd.contains(SCRIPT_FILE_NAME));
|
||||
@@ -158,7 +223,10 @@ fn install_is_idempotent() {
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||
assert_eq!(once, twice);
|
||||
assert_eq!(installed_commands(&settings_path(home)).len(), 3);
|
||||
assert_eq!(
|
||||
installed_commands(&settings_path(home)).len(),
|
||||
HOOK_EVENTS.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// An older maestro entry under the same events is updated in place, not
|
||||
@@ -188,7 +256,11 @@ fn install_updates_an_older_maestro_hook_in_place() {
|
||||
install(&settings, &new_script).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(cmds.len(), 3, "old entry replaced, not kept: {cmds:?}");
|
||||
assert_eq!(
|
||||
cmds.len(),
|
||||
HOOK_EVENTS.len(),
|
||||
"old entry replaced, not kept: {cmds:?}"
|
||||
);
|
||||
assert!(!cmds.iter().any(|c| c == old_script));
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||
@@ -231,11 +303,11 @@ fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
assert_eq!(pre[0]["hooks"][0]["command"], "my-own-linter.sh");
|
||||
// Stale maestro event gone.
|
||||
assert!(root["hooks"].get("UserPromptSubmit").is_none());
|
||||
// Our three events present, nothing else maestro-related anywhere.
|
||||
// Our events present, nothing else maestro-related anywhere.
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(
|
||||
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
|
||||
3,
|
||||
HOOK_EVENTS.len(),
|
||||
"{cmds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,12 +160,26 @@ impl Sidebar {
|
||||
spans.push(Span::styled(" ", base));
|
||||
spans.push(Span::styled(format_state_duration(d), dot_style));
|
||||
}
|
||||
// The trailing fragments are measured up front so a long detail
|
||||
// truncates ahead of them instead of pushing them off the line
|
||||
// (docs/SIDEBAR.md).
|
||||
let mut tail: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(b) = blocked {
|
||||
spans.push(Span::styled(format!(" ⚠ {b}"), muted));
|
||||
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
|
||||
}
|
||||
if dead {
|
||||
spans.push(Span::styled(" exited", muted));
|
||||
tail.push(Span::styled(" exited", muted));
|
||||
}
|
||||
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
|
||||
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
|
||||
let budget = width.saturating_sub(head_w + tail_w);
|
||||
spans.extend(fit_to_width(
|
||||
vec![Span::styled(format!(" {detail}"), muted)],
|
||||
budget,
|
||||
));
|
||||
}
|
||||
spans.extend(tail);
|
||||
Some(fit_to_width(spans, width))
|
||||
}
|
||||
}
|
||||
@@ -531,6 +545,93 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #112: the agent-reported detail sits right after the duration on the
|
||||
/// meta line, in the muted style (docs/SIDEBAR.md).
|
||||
#[test]
|
||||
fn errored_session_shows_its_reported_detail_on_the_meta_line() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity = ActivityState::Error;
|
||||
s.activity_since = Utc::now() - Duration::seconds(90);
|
||||
s.activity_detail = Some("cargo build failed".into());
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = &all[idx + 1];
|
||||
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
|
||||
assert!(
|
||||
meta.contains("1m"),
|
||||
"duration precedes the detail: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
meta.contains("cargo build failed"),
|
||||
"reported detail must render: {meta:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A fresh Error report earns line 2 on its own (contract rule 4), with
|
||||
/// the detail even though no duration has accrued.
|
||||
#[test]
|
||||
fn fresh_errored_session_earns_a_meta_line_for_its_detail() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity = ActivityState::Error;
|
||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||
s.activity_detail = Some("waiting for approval".into());
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
assert!(
|
||||
all[idx + 1].contains("waiting for approval"),
|
||||
"detail must render on a fresh report's own meta line: {:?}",
|
||||
all[idx + 1]
|
||||
);
|
||||
}
|
||||
|
||||
/// #112 via docs/SIDEBAR.md: the detail truncates with `…` ahead of the
|
||||
/// trailing fragments — a long detail never pushes blocked work or
|
||||
/// `exited` off the line, and never wraps.
|
||||
#[test]
|
||||
fn long_detail_yields_to_blocked_work_and_exited() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.status = SessionStatus::Dead;
|
||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||
s.activity_detail =
|
||||
Some("an absurdly long error message that cannot possibly fit ".repeat(3));
|
||||
s.blocked_work = Some(InTheWay {
|
||||
unpushed_commits: 2,
|
||||
..InTheWay::default()
|
||||
});
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = content(&all[idx + 1]);
|
||||
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
|
||||
assert!(
|
||||
meta.contains("2 unpushed commits"),
|
||||
"blocked work stays on the line: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
meta.contains("exited"),
|
||||
"exited fragment stays on the line: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
content(&all[idx + 2]).trim().is_empty(),
|
||||
"meta line must not wrap: {:?}",
|
||||
all[idx + 2]
|
||||
);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: a payload predating #102 carries an epoch
|
||||
/// `activity_since` and so contributes no duration — a card must not claim
|
||||
/// the session has been idle since 1970. The dot still renders when some
|
||||
|
||||
+18
-1
@@ -55,7 +55,10 @@ only when their trigger holds. A payload that predates #102 (no usable
|
||||
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
|
||||
`Error`. It sits right after the duration, in the muted style, and shows only
|
||||
while an agent-reported state is in force (see Precedence): when the report
|
||||
lapses at its TTL or is replaced, the detail goes with it.
|
||||
lapses at its TTL or is replaced, the detail goes with it. The detail
|
||||
truncates with `…` ahead of the trailing fragments — a long detail never
|
||||
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
|
||||
client ever lets it wrap.
|
||||
|
||||
`<command>` is optional and muted: the session's command line, shown only when
|
||||
the rendered name came from `metadata.name` and differs from the command (the
|
||||
@@ -104,6 +107,20 @@ When the TTL expires, inference resumes and the session's reported detail —
|
||||
if any — clears with the report. Clients never blend the two: whichever source
|
||||
is in force supplies both the colour and the clock for `activity_since`.
|
||||
|
||||
Two rulings specific to a reported `Error` (#114):
|
||||
|
||||
- **The five-minute TTL applies to `Error` like to any other state.** A
|
||||
session that failed is still failed after five minutes, but a card pinned
|
||||
red forever lies once the session moves on; when the report lapses the card
|
||||
returns to the daemon's own inference — usually `Idle` for a session that
|
||||
has gone quiet.
|
||||
- **Only a new report clears an `Error` before its TTL.** The next
|
||||
`ActivityState` of any kind replaces it (with #106's hook that is typically
|
||||
`PreToolUse` → `Active` when the user prompts again). PTY output and
|
||||
process changes do not clear it — only the agent itself may withdraw what
|
||||
the agent itself reported. Re-reporting `Error` extends the TTL rather than
|
||||
stacking reports.
|
||||
|
||||
If a client receives an `ActivityState` variant it does not know, it must not
|
||||
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
|
||||
until the daemon sends a variant it does know. Rust clients deserialize
|
||||
|
||||
@@ -105,6 +105,38 @@ describe("blocked-work indicator (#83)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent-reported detail (#112)", () => {
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
it("renders the reported detail right after the duration on line 2", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000020",
|
||||
status: "Dead",
|
||||
activity: "Error",
|
||||
activity_since: ago(90_000),
|
||||
activity_detail: "cargo build failed",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
|
||||
const order = [...line2!.children].map((c) => c.className);
|
||||
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
|
||||
expect(order.indexOf("activity-detail")).toBeLessThan(
|
||||
order.findIndex((c) => c.startsWith("card-flag")),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the detail when no report is in force", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000021",
|
||||
activity: "Active",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session cards (#107)", () => {
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ function metaLine(s: Session, now: number): string {
|
||||
const fragments = [
|
||||
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
|
||||
duration ? `<span class="activity-duration">${duration}</span>` : "",
|
||||
s.activity_detail
|
||||
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
|
||||
: "",
|
||||
blockers
|
||||
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
|
||||
: "",
|
||||
|
||||
@@ -851,6 +851,17 @@ textarea {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Agent-reported detail (#112): sits after the duration, shrinks with an
|
||||
ellipsis so it never pushes the trailing fragments off the line. */
|
||||
.activity-detail {
|
||||
color: var(--faint);
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Blocked-work / exited / command fragments recede: the ⚠ glyph already
|
||||
signals severity and "exited" dims with the row. */
|
||||
.card-flag {
|
||||
|
||||
Reference in New Issue
Block a user