diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 53feb7e5..3798351a 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -756,6 +756,16 @@ window.marked = marked; const h = Math.floor(m / 60); return h + 'h ' + (m % 60) + 'm'; } + // Wall-clock HH:MM:SS (UTC, matching the inbox timestamps on this page) + // from a unix-seconds value. Used to label turn-start / turn-end rows + // when the event carries a `ts` (see the turn renderers below). + function fmtClock(sec) { + return new Date(sec * 1000).toISOString().slice(11, 19); + } + // Unix-seconds stamp of the most recent open turn-start, so the + // matching turn-end can show a duration. Turns are sequential, so a + // single slot is enough (history replays chronologically too). + let pendingTurnStartTs = null; const STATE_TOOLTIPS = { loading: 'harness not yet contacted', offline: 'harness unreachable or claude not logged in', @@ -1697,6 +1707,16 @@ window.marked = marked; if (api.fromHistory) openTurnsFromHistory += 1; else { setBannerActive(true); setState('thinking'); } const block = api.row('turn-start', '◆ TURN ← ' + ev.from); + // Turn start time. Guarded on a numeric `ts` (unix seconds) + // so the row degrades to its old text-only form until the + // backend surfaces per-event timestamps. + if (typeof ev.ts === 'number') { + pendingTurnStartTs = ev.ts; + const t = document.createElement('span'); + t.className = 'turn-time'; + t.textContent = '· ' + fmtClock(ev.ts); + block.appendChild(t); + } if (ev.unread > 0) { const badge = document.createElement('span'); badge.className = 'unread-badge'; @@ -1717,9 +1737,23 @@ window.marked = marked; refreshLooseEnds(); } const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; - api.row(cls, + const row = api.row(cls, (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : '')); + // Turn end time + duration since the paired turn-start. + // Same `ts` guard as turn_start; duration only when we saw the + // matching start's stamp. + if (typeof ev.ts === 'number') { + const t = document.createElement('span'); + t.className = 'turn-time'; + let label = '· ' + fmtClock(ev.ts); + if (pendingTurnStartTs != null && ev.ts >= pendingTurnStartTs) { + label += ' · ' + fmtAge((ev.ts - pendingTurnStartTs) * 1000); + } + t.textContent = label; + row.appendChild(t); + } + pendingTurnStartTs = null; }, note(ev, api) { const t = String(ev.text || ''); diff --git a/frontend/packages/shared/src/terminal.css b/frontend/packages/shared/src/terminal.css index 2cfe7dee..37413827 100644 --- a/frontend/packages/shared/src/terminal.css +++ b/frontend/packages/shared/src/terminal.css @@ -100,6 +100,10 @@ .live .row .md, .live .row > details { text-indent: 0; } .live .turn-end-ok { color: var(--green); border-left-color: var(--green); } .live .turn-end-fail { color: var(--red); border-left-color: var(--red); } +/* Wall-clock time (+ duration on turn-end) appended to the turn-start / + turn-end rows. Dim + smaller so the boundary glyph stays the focus and + the timestamp reads as metadata. */ +.live .turn-time { color: var(--muted); font-size: 0.85em; margin-left: 0.5em; } .live .text { color: var(--fg); } .live .thinking { color: var(--muted); font-style: italic; } .live .tool-use { color: var(--cyan); }