feat: show structured turn activity
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall};
|
||||
use maestro_protocol::{
|
||||
ActivityState, ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind,
|
||||
@@ -37,6 +38,8 @@ pub struct Projection {
|
||||
pending_tool_updates: HashMap<String, Vec<maestro_acp::schema::ToolCallUpdateFields>>,
|
||||
state: ReducedState,
|
||||
active_turn: Option<u64>,
|
||||
active_turn_started_at: Option<DateTime<Utc>>,
|
||||
active_turn_context_tokens: Option<u64>,
|
||||
turn_failed: bool,
|
||||
next_turn: u64,
|
||||
turn_tool_calls: HashSet<String>,
|
||||
@@ -132,9 +135,15 @@ impl Projection {
|
||||
self.next_turn += 1;
|
||||
let turn = self.next_turn;
|
||||
self.active_turn = Some(turn);
|
||||
let started_at = Utc::now();
|
||||
let started_context_tokens = usage_tokens(self.state.usage.as_ref());
|
||||
self.active_turn_started_at = Some(started_at);
|
||||
self.active_turn_context_tokens = started_context_tokens;
|
||||
self.turn_failed = false;
|
||||
self.state.turn.responding = true;
|
||||
self.state.turn.thinking = true;
|
||||
self.state.turn.started_at = Some(started_at);
|
||||
self.state.turn.started_context_tokens = started_context_tokens;
|
||||
self.turn_tool_calls.clear();
|
||||
self.state.turn.active_tool_calls.clear();
|
||||
(
|
||||
@@ -163,10 +172,44 @@ impl Projection {
|
||||
self.turn_failed = lifecycle == TurnLifecycle::Failure;
|
||||
self.state.turn.responding = false;
|
||||
self.state.turn.thinking = false;
|
||||
let finished_at = Utc::now();
|
||||
let started_at = self.active_turn_started_at.take();
|
||||
let started_context_tokens = self.active_turn_context_tokens.take();
|
||||
self.state.turn.started_at = None;
|
||||
self.state.turn.started_context_tokens = None;
|
||||
self.turn_tool_calls.clear();
|
||||
self.state.turn.active_tool_calls.clear();
|
||||
let context_tokens = usage_tokens(self.state.usage.as_ref());
|
||||
let context_delta = context_tokens
|
||||
.zip(started_context_tokens)
|
||||
.map(|(end, start)| i128::from(end) - i128::from(start));
|
||||
let elapsed_ms = started_at.map_or(0, |started| {
|
||||
finished_at
|
||||
.signed_duration_since(started)
|
||||
.num_milliseconds()
|
||||
.max(0)
|
||||
});
|
||||
let outcome = match lifecycle {
|
||||
TurnLifecycle::Finish => "completed",
|
||||
TurnLifecycle::Cancel => "cancelled",
|
||||
TurnLifecycle::Failure => "failed",
|
||||
TurnLifecycle::Begin => unreachable!(),
|
||||
};
|
||||
let row = self.append(
|
||||
format!("turn_meta:{}", uuid::Uuid::new_v4()),
|
||||
TranscriptRowKind::Notice,
|
||||
serde_json::json!({
|
||||
"maestroTurnMeta": {
|
||||
"elapsedMs": elapsed_ms,
|
||||
"contextTokens": context_tokens,
|
||||
"contextDelta": context_delta,
|
||||
"outcome": outcome,
|
||||
"model": self.state.model,
|
||||
}
|
||||
}),
|
||||
);
|
||||
Some(ProjectionUpdate {
|
||||
transcript: None,
|
||||
transcript: Some(row),
|
||||
state: self.state(),
|
||||
})
|
||||
}
|
||||
@@ -413,6 +456,10 @@ impl Projection {
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_tokens(usage: Option<&Value>) -> Option<u64> {
|
||||
usage?.get("used")?.as_u64()
|
||||
}
|
||||
|
||||
fn to_value(value: &impl serde::Serialize) -> Value {
|
||||
serde_json::to_value(value).expect("ACP schema values serialize")
|
||||
}
|
||||
@@ -550,9 +597,10 @@ mod tests {
|
||||
);
|
||||
|
||||
let rows = projection.snapshot(4).rows;
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "pong");
|
||||
assert_eq!(rows[1].data.pointer("/content/text").unwrap(), "next");
|
||||
assert!(rows[1].data.get("maestroTurnMeta").is_some());
|
||||
assert_eq!(rows[2].data.pointer("/content/text").unwrap(), "next");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -797,6 +845,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_turn_keeps_duration_and_context_delta_in_transcript() {
|
||||
let mut projection = Projection::default();
|
||||
projection.apply(
|
||||
1,
|
||||
&event(serde_json::json!({
|
||||
"sessionUpdate":"usage_update", "used":10_000, "size":200_000
|
||||
})),
|
||||
);
|
||||
let (turn, begun) = projection.begin_turn();
|
||||
assert!(begun.state.turn.started_at.is_some());
|
||||
assert_eq!(begun.state.turn.started_context_tokens, Some(10_000));
|
||||
projection.apply(
|
||||
2,
|
||||
&event(serde_json::json!({
|
||||
"sessionUpdate":"usage_update", "used":12_500, "size":200_000
|
||||
})),
|
||||
);
|
||||
|
||||
let finished = projection
|
||||
.turn_lifecycle(TurnLifecycle::Finish, turn)
|
||||
.expect("active turn finishes");
|
||||
let TranscriptDelta::Append { row } = finished.transcript.unwrap() else {
|
||||
panic!("turn footer");
|
||||
};
|
||||
assert_eq!(row.kind, TranscriptRowKind::Notice);
|
||||
assert_eq!(row.data["maestroTurnMeta"]["contextDelta"], 2_500);
|
||||
assert_eq!(row.data["maestroTurnMeta"]["contextTokens"], 12_500);
|
||||
assert_eq!(row.data["maestroTurnMeta"]["outcome"], "completed");
|
||||
assert!(finished.state.turn.started_at.is_none());
|
||||
}
|
||||
|
||||
fn terminal_turn_drops_unfinished_tools(
|
||||
terminal: TurnLifecycle,
|
||||
unfinished_id: &str,
|
||||
@@ -815,7 +895,9 @@ mod tests {
|
||||
|
||||
projection.turn_lifecycle(terminal, turn);
|
||||
assert!(projection.state().turn.active_tool_calls.is_empty());
|
||||
assert_eq!(projection.snapshot(1).rows.len(), 1);
|
||||
let rows = projection.snapshot(1).rows;
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert!(rows[1].data.get("maestroTurnMeta").is_some());
|
||||
|
||||
projection.begin_turn();
|
||||
projection.apply(
|
||||
|
||||
@@ -657,6 +657,7 @@ impl SessionManager {
|
||||
let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let projected = persist_turn_update(&mut stream, projected);
|
||||
let _ = stream.projected_live.send(projected);
|
||||
drop(stream);
|
||||
sync_structured_activity(&self.inner, id).await;
|
||||
@@ -774,6 +775,7 @@ impl SessionManager {
|
||||
.expect(
|
||||
"newly claimed turn remains current while stream is locked",
|
||||
);
|
||||
let projected = persist_turn_update(&mut stream, projected);
|
||||
let _ = stream.projected_live.send(projected);
|
||||
drop(stream);
|
||||
sync_structured_activity(&self.inner, id).await;
|
||||
@@ -819,6 +821,7 @@ impl SessionManager {
|
||||
}
|
||||
cancel_pending_approvals(&mut stream);
|
||||
if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) {
|
||||
let projected = persist_turn_update(&mut stream, projected);
|
||||
let _ = stream.projected_live.send(projected);
|
||||
}
|
||||
drop(stream);
|
||||
@@ -935,6 +938,7 @@ impl SessionManager {
|
||||
.projection
|
||||
.turn_lifecycle(TurnLifecycle::Cancel, turn)
|
||||
.expect("active turn remains current while stream is locked");
|
||||
let projected = persist_turn_update(&mut stream, projected);
|
||||
let _ = stream.projected_live.send(projected);
|
||||
drop(stream);
|
||||
sync_structured_activity(&self.inner, id).await;
|
||||
@@ -2190,10 +2194,31 @@ fn start_acp_event_pump(
|
||||
fn apply_persisted_event(projection: &mut Projection, event: &LoggedEvent) -> ProjectionUpdate {
|
||||
match &event.event {
|
||||
PersistedEvent::Notification(notification) => projection.apply(event.seq, notification),
|
||||
PersistedEvent::User { row } | PersistedEvent::Approval { row } => {
|
||||
projection.restore_row(row.clone())
|
||||
PersistedEvent::User { row }
|
||||
| PersistedEvent::Approval { row }
|
||||
| PersistedEvent::Turn { row } => projection.restore_row(row.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_turn_update(
|
||||
stream: &mut AcpEventStream,
|
||||
mut update: ProjectionUpdate,
|
||||
) -> ProjectionUpdate {
|
||||
let Some(TranscriptDelta::Append { row }) = update.transcript.as_ref() else {
|
||||
return update;
|
||||
};
|
||||
let event = PersistedEvent::Turn { row: row.clone() };
|
||||
match stream.log.append(&event) {
|
||||
Ok(seq) => {
|
||||
let _ = stream.live.send(LoggedEvent { seq, event });
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "turn metadata history append failed");
|
||||
stream.projection.rollback_row(&row.id);
|
||||
update.transcript = None;
|
||||
}
|
||||
}
|
||||
update
|
||||
}
|
||||
|
||||
async fn sync_structured_activity(inner: &Arc<Inner>, id: Uuid) {
|
||||
|
||||
@@ -269,6 +269,9 @@ pub enum PersistedEvent {
|
||||
Approval {
|
||||
row: TranscriptRow,
|
||||
},
|
||||
Turn {
|
||||
row: TranscriptRow,
|
||||
},
|
||||
#[serde(skip)]
|
||||
Notification(Box<SessionNotification>),
|
||||
}
|
||||
|
||||
@@ -349,9 +349,27 @@ async fn projection_snapshot_and_live_stream_share_one_fold() {
|
||||
.await
|
||||
.expect("finish lifecycle broadcast")
|
||||
.expect("projection broadcast");
|
||||
assert_eq!(finish.transcript, None);
|
||||
let Some(TranscriptDelta::Append { row }) = finish.transcript else {
|
||||
panic!("finish should append durable turn metadata");
|
||||
};
|
||||
assert_eq!(row.kind, TranscriptRowKind::Notice);
|
||||
assert_eq!(row.data["maestroTurnMeta"]["outcome"], "completed");
|
||||
assert_eq!(finish.state.turn, Default::default());
|
||||
|
||||
let recovered = fx
|
||||
.manager
|
||||
.read_acp_projection_replay(session.id, EventCursor::Since(0), 10)
|
||||
.await
|
||||
.expect("turn replay")
|
||||
.expect("structured session");
|
||||
assert!(recovered.iter().any(|event| {
|
||||
matches!(
|
||||
&event.transcript,
|
||||
Some(TranscriptDelta::Append { row })
|
||||
if row.data["maestroTurnMeta"]["outcome"] == "completed"
|
||||
)
|
||||
}));
|
||||
|
||||
fx.manager.kill(session.id, false).await.expect("kill");
|
||||
}
|
||||
|
||||
|
||||
@@ -824,6 +824,15 @@ pub struct TurnState {
|
||||
pub thinking: bool,
|
||||
#[serde(default)]
|
||||
pub active_tool_calls: Vec<String>,
|
||||
/// Wall-clock start of the active prompt. This lets reconnecting clients
|
||||
/// keep one continuous elapsed-time display instead of restarting at zero.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
/// Context-window usage when the active prompt began. ACP exposes context
|
||||
/// occupancy, not provider billing-token accounting, so clients must label
|
||||
/// deltas as context tokens rather than tokens billed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started_context_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// A whole-session view derived from ACP notifications.
|
||||
|
||||
+10
-4
@@ -113,7 +113,7 @@ crate.
|
||||
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
|
||||
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
|
||||
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
|
||||
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. | `notice:<seq>`. |
|
||||
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:<seq>` for ACP updates; `turn_meta:<uuid>` for a turn footer. |
|
||||
|
||||
`Notice` covers known-but-unspecialized `SessionUpdate` variants: ones the
|
||||
`agent-client-protocol` dependency's schema already deserializes (it
|
||||
@@ -147,7 +147,10 @@ choices, not daemon ones.
|
||||
TUI renderers can never disagree about it.
|
||||
- Turn lifecycle: when a turn begins, and whether an ACP response, a cancel,
|
||||
or a failure ends it (`TurnState.responding` / `.thinking` /
|
||||
`.active_tool_calls` are derived, not renderer-computed).
|
||||
`.active_tool_calls` / `.started_at` are derived, not renderer-computed).
|
||||
Ending a turn appends a durable `maestroTurnMeta` footer with its elapsed
|
||||
time, outcome, model, and context-window usage/delta. ACP does not expose
|
||||
provider billing-token counts, so these are explicitly context tokens.
|
||||
- Approval nonce minting, secrecy, and the single-active-approval rule (see
|
||||
below) — a client never sees an ACP permission request before the daemon
|
||||
has already turned it into an `Approval` row and a nonce.
|
||||
@@ -177,7 +180,9 @@ choices, not daemon ones.
|
||||
the caret. Completing a command replaces only that token. The client omits
|
||||
those state-only notices, along with `session_info_update`, from the visible
|
||||
transcript.
|
||||
- How `responding` vs. `thinking` are distinguished visually, if at all.
|
||||
- How live `responding`, `thinking`, tool activity, elapsed time, and context
|
||||
usage are drawn. The web client keeps these in a compact strip above the
|
||||
composer and renders each durable turn footer inline with the transcript.
|
||||
- How the operator is asked to resolve an `Approval` row — the daemon only
|
||||
exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`),
|
||||
never the UI that solicits it.
|
||||
@@ -209,7 +214,8 @@ every row, once appended, is only ever patched or left alone.
|
||||
The event log's own line position is the cursor: 1-based, shared by ACP
|
||||
notifications and daemon-generated rows alike (`sidecar.rs`'s
|
||||
`PersistedEvent` envelope is what lets `User` and `Approval` rows share a cursor
|
||||
sequence with raw ACP JSON without pretending to be ACP traffic). Two ways to
|
||||
sequence with raw ACP JSON without pretending to be ACP traffic; the envelope
|
||||
also carries durable `Turn` footers). Two ways to
|
||||
read it:
|
||||
|
||||
- **`WS /api/structured/:id`** — on connect, sends a `TranscriptSnapshot` and
|
||||
|
||||
@@ -182,7 +182,9 @@ describe("structured connection: data flow", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(conn.el.querySelector(".structured-config-summary")?.textContent).toBe("Opus · High");
|
||||
expect(conn.el.querySelector(".structured-config-trigger")?.getAttribute("aria-label")).toBe(
|
||||
"Session settings. Model: Opus. Effort: High",
|
||||
);
|
||||
(conn.el.querySelector(".structured-config-trigger") as HTMLButtonElement).click();
|
||||
const selects = conn.el.querySelectorAll<HTMLSelectElement>(".structured-config-select");
|
||||
expect(selects).toHaveLength(2);
|
||||
@@ -587,6 +589,41 @@ describe("structured connection: turn/composer gating", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("shows live thinking time, tool activity, and context usage", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
const activity = conn.el.querySelector<HTMLElement>(".structured-activity")!;
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: {
|
||||
turn: {
|
||||
responding: true,
|
||||
thinking: true,
|
||||
active_tool_calls: [],
|
||||
started_at: new Date().toISOString(),
|
||||
started_context_tokens: 10_000,
|
||||
},
|
||||
usage: { used: 12_400, size: 200_000 },
|
||||
},
|
||||
});
|
||||
expect(activity.hidden).toBe(false);
|
||||
expect(activity.textContent).toContain("Thinking · 0s · 12.4k context / 200k context");
|
||||
|
||||
ws.emit({
|
||||
type: "reduced_state",
|
||||
state: {
|
||||
turn: { responding: true, thinking: false, active_tool_calls: ["bash-1", "read-1"] },
|
||||
usage: { used: 12_800, size: 200_000 },
|
||||
},
|
||||
});
|
||||
expect(activity.textContent).toContain("Running 2 tools");
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(activity.hidden).toBe(true);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("sends a prompt on Enter, clears the draft, and restores it on failure", async () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
|
||||
+55
-5
@@ -18,7 +18,12 @@ import type { CatalogStore } from "./events.js";
|
||||
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
|
||||
import type { PaneConnection } from "./pane.js";
|
||||
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
|
||||
import { buildToolGroup, buildTranscriptRow } from "./structuredRender.js";
|
||||
import {
|
||||
buildToolGroup,
|
||||
buildTranscriptRow,
|
||||
formatContextTokens,
|
||||
formatElapsed,
|
||||
} from "./structuredRender.js";
|
||||
import type {
|
||||
AcpSessionConfigChoice,
|
||||
AcpSessionConfigOption,
|
||||
@@ -80,6 +85,10 @@ export function createStructuredConnection(
|
||||
<div class="structured-scroll">
|
||||
<div class="structured-rows"></div>
|
||||
</div>
|
||||
<div class="structured-activity" role="status" aria-live="polite" hidden>
|
||||
<span class="structured-activity-pulse" aria-hidden="true"></span>
|
||||
<span class="structured-activity-text">Thinking</span>
|
||||
</div>
|
||||
<form class="structured-composer">
|
||||
<div class="structured-input-wrap">
|
||||
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
|
||||
@@ -90,7 +99,9 @@ export function createStructuredConnection(
|
||||
</div>
|
||||
<div class="structured-config" hidden>
|
||||
<button type="button" class="structured-config-trigger" aria-haspopup="true"
|
||||
aria-expanded="false"><span class="structured-config-summary">Session settings</span><span class="structured-config-caret" aria-hidden="true"></span></button>
|
||||
aria-expanded="false" aria-label="Session settings" title="Session settings">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10M14 4v6M6 14v6"/></svg>
|
||||
</button>
|
||||
<div class="structured-config-menu" role="group" aria-label="Session settings" hidden></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary structured-send">Send</button>
|
||||
@@ -103,11 +114,12 @@ export function createStructuredConnection(
|
||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||
const configEl = el.querySelector(".structured-config") as HTMLDivElement;
|
||||
const configTrigger = el.querySelector(".structured-config-trigger") as HTMLButtonElement;
|
||||
const configSummary = el.querySelector(".structured-config-summary") as HTMLSpanElement;
|
||||
const configMenu = el.querySelector(".structured-config-menu") as HTMLDivElement;
|
||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||
const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement;
|
||||
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
|
||||
const activityEl = el.querySelector(".structured-activity") as HTMLDivElement;
|
||||
const activityText = el.querySelector(".structured-activity-text") as HTMLSpanElement;
|
||||
const composerForm = el.querySelector(".structured-composer") as HTMLFormElement;
|
||||
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
|
||||
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
@@ -147,6 +159,8 @@ export function createStructuredConnection(
|
||||
let socketGeneration = 0;
|
||||
let stateSocketGeneration = 0;
|
||||
let disposed = false;
|
||||
let optimisticTurnStartedAt: number | null = null;
|
||||
let activityTimer: number | null = null;
|
||||
|
||||
// Within 64px, wheel and touch imprecision should still count as following.
|
||||
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||
@@ -420,8 +434,9 @@ export function createStructuredConnection(
|
||||
const bRank = rank(b.category);
|
||||
return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank);
|
||||
});
|
||||
configSummary.textContent = ordered.map(currentConfigLabel).join(" · ");
|
||||
configTrigger.title = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n");
|
||||
const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n");
|
||||
configTrigger.title = summary;
|
||||
configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`);
|
||||
configTrigger.disabled = configBusy;
|
||||
configMenu.replaceChildren();
|
||||
for (const option of ordered) {
|
||||
@@ -467,6 +482,39 @@ export function createStructuredConnection(
|
||||
sendBtn.disabled = active;
|
||||
cancelBtn.hidden = !state.turn.responding && !state.turn.thinking;
|
||||
if (active) closeCommandMenu();
|
||||
renderActivity();
|
||||
}
|
||||
|
||||
function liveElapsedMs(): number {
|
||||
const authoritative = state.turn.started_at ? Date.parse(state.turn.started_at) : Number.NaN;
|
||||
const startedAt = Number.isFinite(authoritative) ? authoritative : optimisticTurnStartedAt;
|
||||
return startedAt === null ? 0 : Math.max(0, Date.now() - startedAt);
|
||||
}
|
||||
|
||||
function renderActivity(): void {
|
||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||
activityEl.hidden = !active;
|
||||
if (!active) {
|
||||
optimisticTurnStartedAt = null;
|
||||
if (activityTimer !== null) window.clearInterval(activityTimer);
|
||||
activityTimer = null;
|
||||
return;
|
||||
}
|
||||
const tools = state.turn.active_tool_calls.length;
|
||||
const label = awaitingBeginGeneration !== null
|
||||
? "Sending"
|
||||
: state.turn.thinking
|
||||
? "Thinking"
|
||||
: tools > 0
|
||||
? `Running ${tools} ${tools === 1 ? "tool" : "tools"}`
|
||||
: "Responding";
|
||||
const parts = [label, formatElapsed(liveElapsedMs())];
|
||||
if (typeof state.usage?.used === "number") {
|
||||
const usage = formatContextTokens(state.usage.used);
|
||||
parts.push(typeof state.usage.size === "number" ? `${usage} / ${formatContextTokens(state.usage.size)}` : usage);
|
||||
}
|
||||
activityText.textContent = parts.join(" · ");
|
||||
if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000);
|
||||
}
|
||||
|
||||
function reconcileAwaitingBegin(): void {
|
||||
@@ -699,6 +747,7 @@ export function createStructuredConnection(
|
||||
const generation = ++submissionGeneration;
|
||||
const submissionSocketGeneration = socketGeneration;
|
||||
awaitingBeginGeneration = generation;
|
||||
optimisticTurnStartedAt = Date.now();
|
||||
acceptedSubmission = null;
|
||||
input.value = "";
|
||||
autoResize();
|
||||
@@ -884,6 +933,7 @@ export function createStructuredConnection(
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (activityTimer !== null) window.clearInterval(activityTimer);
|
||||
mountToken++;
|
||||
scrollWorkActive = false;
|
||||
cancelScrollFrame();
|
||||
|
||||
@@ -332,6 +332,24 @@ describe("buildTranscriptRow: approval", () => {
|
||||
});
|
||||
|
||||
describe("buildTranscriptRow: notice / unknown kinds", () => {
|
||||
it("renders durable per-turn duration and context usage", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("notice", {
|
||||
maestroTurnMeta: {
|
||||
elapsedMs: 72_000,
|
||||
contextTokens: 18_400,
|
||||
contextDelta: 2_350,
|
||||
outcome: "completed",
|
||||
model: "opus",
|
||||
},
|
||||
}, "turn:1"),
|
||||
)!;
|
||||
|
||||
expect(el.classList.contains("st-turn-meta")).toBe(true);
|
||||
expect(el.textContent).toContain("1m 12s · +2.4k context tokens · opus");
|
||||
expect(el.getAttribute("aria-label")).toBe("Copy turn details");
|
||||
});
|
||||
|
||||
it("renders notice and unknown row kinds safely", () => {
|
||||
expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe(
|
||||
"[notice]",
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AcpToolCallLike,
|
||||
StructuredApprovalDecision,
|
||||
TranscriptRow,
|
||||
TurnMeta,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -405,6 +406,54 @@ function buildPlainRow(row: TranscriptRow): HTMLElement {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function compactNumber(value: number): string {
|
||||
if (Math.abs(value) < 1_000) return String(value);
|
||||
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
|
||||
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
|
||||
}
|
||||
|
||||
export function formatElapsed(milliseconds: number): string {
|
||||
const seconds = Math.max(0, Math.floor(milliseconds / 1_000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
|
||||
}
|
||||
|
||||
export function formatContextTokens(value: number): string {
|
||||
return `${compactNumber(value)} context`;
|
||||
}
|
||||
|
||||
function buildTurnMetaRow(meta: TurnMeta): HTMLElement {
|
||||
const wrap = el("div", `st-turn-meta st-turn-meta-${meta.outcome}`);
|
||||
wrap.setAttribute("aria-label", `Turn ${meta.outcome}`);
|
||||
const parts = [formatElapsed(meta.elapsedMs)];
|
||||
if (typeof meta.contextDelta === "number") {
|
||||
const sign = meta.contextDelta > 0 ? "+" : "";
|
||||
parts.push(`${sign}${compactNumber(meta.contextDelta)} context tokens`);
|
||||
} else if (typeof meta.contextTokens === "number") {
|
||||
parts.push(formatContextTokens(meta.contextTokens));
|
||||
}
|
||||
if (meta.model) parts.push(meta.model);
|
||||
if (meta.outcome !== "completed") parts.push(meta.outcome);
|
||||
const dot = el("span", "st-turn-meta-dot");
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
const text = el("span");
|
||||
text.textContent = parts.join(" · ");
|
||||
wrap.append(dot, text);
|
||||
makeCopyable(wrap, text.textContent, "turn details");
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function turnMeta(row: TranscriptRow): TurnMeta | null {
|
||||
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return null;
|
||||
const value = (row.data as { maestroTurnMeta?: unknown }).maestroTurnMeta;
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const meta = value as Partial<TurnMeta>;
|
||||
if (typeof meta.elapsedMs !== "number" || typeof meta.outcome !== "string") return null;
|
||||
return meta as TurnMeta;
|
||||
}
|
||||
|
||||
function isStateOnlyNotice(row: TranscriptRow): boolean {
|
||||
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return false;
|
||||
const update = (row.data as { sessionUpdate?: string }).sessionUpdate;
|
||||
@@ -555,7 +604,7 @@ export function buildTranscriptRow(
|
||||
return null;
|
||||
case "notice":
|
||||
if (isStateOnlyNotice(row)) return null;
|
||||
built = buildPlainRow(row);
|
||||
built = turnMeta(row) ? buildTurnMetaRow(turnMeta(row)!) : buildPlainRow(row);
|
||||
break;
|
||||
default:
|
||||
built = buildPlainRow(row);
|
||||
|
||||
+88
-32
@@ -2175,25 +2175,32 @@ input.leaf-rename:focus {
|
||||
|
||||
.structured-config {
|
||||
position: relative;
|
||||
flex: 0 1 210px;
|
||||
min-width: 0;
|
||||
flex: 0 0 var(--control-h);
|
||||
}
|
||||
|
||||
.structured-config-trigger {
|
||||
width: 100%;
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 0 var(--sp-2);
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
font: 500 var(--fs-11) / 1 var(--font-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.structured-config-trigger svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.75;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.structured-config-trigger:hover,
|
||||
.structured-config-trigger[aria-expanded="true"] {
|
||||
color: var(--ink);
|
||||
@@ -2208,26 +2215,6 @@ input.leaf-rename:focus {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.structured-config-summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.structured-config-caret {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: 0 0 auto;
|
||||
border-right: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
transform: translateY(-1px) rotate(45deg);
|
||||
}
|
||||
|
||||
.structured-config-trigger[aria-expanded="true"] .structured-config-caret {
|
||||
transform: translateY(2px) rotate(225deg);
|
||||
}
|
||||
|
||||
.structured-config-menu {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
@@ -2321,6 +2308,41 @@ input.leaf-rename:focus {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.structured-activity {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: 30px;
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: var(--fs-11);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.structured-activity[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.structured-activity-pulse {
|
||||
position: relative;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--live);
|
||||
box-shadow: 0 0 0 0 var(--live-glow);
|
||||
animation: structured-thinking 1.6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes structured-thinking {
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* every transcript row carries its own layout; none of them are cards */
|
||||
.st-row {
|
||||
display: flex;
|
||||
@@ -2387,6 +2409,40 @@ input.leaf-rename:focus {
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
|
||||
.st-turn-meta {
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 0 var(--sp-3) var(--sp-2);
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
border-radius: var(--r-1);
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-11);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.st-turn-meta:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.st-turn-meta-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--live);
|
||||
}
|
||||
|
||||
.st-turn-meta-cancelled .st-turn-meta-dot {
|
||||
background: var(--warn);
|
||||
}
|
||||
|
||||
.st-turn-meta-failed .st-turn-meta-dot {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.st-copyable {
|
||||
position: relative;
|
||||
border-radius: var(--r-1);
|
||||
@@ -2806,12 +2862,8 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.structured-config {
|
||||
flex-basis: 132px;
|
||||
}
|
||||
|
||||
.structured-config-menu {
|
||||
right: calc(-1 * (var(--control-h) + var(--sp-3)));
|
||||
right: calc(-1 * (var(--control-h) + var(--sp-2)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2912,6 +2964,10 @@ input.leaf-rename:focus {
|
||||
.st-tool-group-glyph {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
|
||||
.structured-activity-pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
|
||||
@@ -422,6 +422,8 @@ export interface TurnState {
|
||||
responding: boolean;
|
||||
thinking: boolean;
|
||||
active_tool_calls: string[];
|
||||
started_at?: string | null;
|
||||
started_context_tokens?: number | null;
|
||||
}
|
||||
|
||||
/** A whole-session view derived from ACP notifications. `plan`/`usage`/
|
||||
@@ -571,6 +573,17 @@ export interface AcpUsage {
|
||||
cost?: { amount: number; currency: string } | null;
|
||||
}
|
||||
|
||||
/** Daemon-owned durable footer appended when one prompt turn settles. ACP
|
||||
* only reports context occupancy, so `contextDelta` is not a billing-token
|
||||
* count and may be negative when the provider compacts the context. */
|
||||
export interface TurnMeta {
|
||||
elapsedMs: number;
|
||||
contextTokens?: number | null;
|
||||
contextDelta?: number | null;
|
||||
outcome: "completed" | "cancelled" | "failed" | string;
|
||||
model?: string | null;
|
||||
}
|
||||
|
||||
export interface AcpPermissionOption {
|
||||
optionId: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user