diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index cf48f05e..2a0e5f2f 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -376,6 +376,61 @@ text-overflow: ellipsis; white-space: nowrap; } + /* Two-line card (TUI full-mode parity): the item is a column of the + original title row plus a detail row (model·effort, context gauge, + activity, tokens). A dedicated `.title-row` wrapper keeps the title line's + flex behavior (name truncates, harness stays right-aligned) instead + of `flex-wrap`, which would push trailing cells onto the next line + rather than shrinking the name. The detail row's left margin tucks + it beneath the name (past the disclosure + status gutter), and the + depth ladder's item padding indents it with the rest of the row. */ + .session-list .item { flex-direction: column; align-items: stretch; } + .session-list .item .title-row { + display: flex; + align-items: center; + min-width: 0; + } + .session-list .item .detail { + display: flex; + align-items: center; + gap: 8px; + margin-left: 36px; + margin-top: 2px; + min-width: 0; + color: var(--fg-faint); + font-size: 11px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + } + .session-list .item .detail > span { flex: none; } + /* The trailing tokens segment is least important: it alone shrinks + (to nothing, past its ellipsis) when the sidebar is narrow, so the + activity text keeps its full width — mirroring the TUI's drop + order, where tokens vanish before activity/model/gauge. */ + .session-list .item .detail .detail-tokens { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + .session-list .item .detail .ctx-gauge { + display: inline-flex; + align-items: center; + gap: 4px; + } + .session-list .item .detail .ctx-bar { + width: 26px; + height: 4px; + border-radius: 2px; + background: color-mix(in srgb, var(--fg-faint) 25%, transparent); + overflow: hidden; + } + .session-list .item .detail .ctx-fill { + height: 100%; + border-radius: 2px; + background: var(--accent); + } .session-list .item .state { flex: 0 0 16px; text-align: center; @@ -3894,6 +3949,7 @@ function refreshSessionActivityGlyphs(now = Date.now()) { refreshSessionListActivityGlyphs(now); + refreshSessionListDetailLive(now); refreshProgramSessionClipGlyphs(now); } @@ -4204,9 +4260,11 @@ datasetVal: s.id, className: "item is-operator" + active, inner: + `
` + `` + sessionStatusHtml(s) + - `
operator
`, + `
operator
` + + `
`, }; } // Gutter cell: the children-disclosure triangle when the session has @@ -4239,9 +4297,12 @@ (s.pinned ? " is-pinned" : "") + (s.archived ? " is-archived" : ""), inner: + `
` + disclosure + sessionStatusHtml(s) + `
${escape(sessionDisplayTitle(s))}
` + - markers + harness, + markers + harness + + `
` + + sessionDetailHtml(s), }; } @@ -11141,6 +11202,106 @@ return s.title || sessionHarnessLabel(s) || s.id.slice(0, 8); } +/* Session-list detail row (TUI full-mode parity, spec 0106): model·effort, + context gauge, activity, tokens — always on (the web list has no + compact/full switch). Absent data is omitted; a session reporting + nothing falls back to where it lives. Cost is deliberately excluded. */ + +/** `950`, `4.2k`, `87k`, `1.3M` — mirrors the TUI's token formatter. */ +function formatTokenCount(n) { + if (n >= 10_000_000) return `${Math.floor(n / 1_000_000)}M`; + if (n >= 1_000_000) return `${(n / 1e6).toFixed(1)}M`; + if (n >= 10_000) return `${Math.floor(n / 1_000)}k`; + if (n >= 1_000) return `${(n / 1e3).toFixed(1)}k`; + return String(n); +} + +/** `3s`, `12m34s` — mirrors the TUI's busy-duration label. */ +function formatDurationMs(ms) { + const secs = Math.floor(Math.max(0, ms) / 1000); + const m = Math.floor(secs / 60); + const s = secs % 60; + return m > 0 ? `${m}m${String(s).padStart(2, "0")}s` : `${s}s`; +} + +/** Coarse single-unit age: `42s`, `12m`, `3h`, `2d`. */ +function formatAgeMs(ms) { + const secs = Math.floor(Math.max(0, ms) / 1000); + if (secs < 60) return `${secs}s`; + if (secs < 3600) return `${Math.floor(secs / 60)}m`; + if (secs < 86_400) return `${Math.floor(secs / 3600)}h`; + return `${Math.floor(secs / 86_400)}d`; +} + +/** `claude-haiku-4-5-20251001` → `haiku-4-5` — drops the vendor prefix + * and a trailing 8-digit date stamp, like the TUI's short model label. */ +function shortModelLabel(model) { + let base = model.startsWith("claude-") ? model.slice(7) : model; + const cut = base.lastIndexOf("-"); + if (cut > 0 && /^\d{8}$/.test(base.slice(cut + 1))) base = base.slice(0, cut); + return base; +} + +/** The live activity segment: ticking busy time while running, coarse + * age since the last chat message otherwise (falling back to the last + * recorded event for message-less sessions — same semantics as the + * TUI, so restart/status noise doesn't reset the age). */ +function sessionDetailLiveText(s, now = Date.now()) { + if ((s.state || "").toLowerCase() === "running") { + const since = s.busy_running_since_ms || now; + return `busy ${formatDurationMs(now - since)}`; + } + const at = s.last_message_at || s.last_event_at; + if (!at) return ""; + const ms = now - new Date(at).getTime(); + return Number.isFinite(ms) ? `${formatAgeMs(ms)} ago` : ""; +} + +function sessionDetailHtml(s) { + const parts = []; + if (s.model) { + const label = shortModelLabel(s.model) + (s.effort ? `·${s.effort}` : ""); + parts.push(`${escape(label)}`); + } + const window = s.context_window || 0; + if (s.context_used != null && window > 0) { + const pct = Math.min(100, Math.round((s.context_used * 100) / window)); + parts.push( + `` + + `${pct}%`, + ); + } else if (s.context_used != null) { + parts.push(`${escape(formatTokenCount(s.context_used))} ctx`); + } + const live = sessionDetailLiveText(s); + // Always emit the live span (even empty) so the activity ticker has a + // stable element to refresh without re-rendering the row. + parts.push(`${escape(live)}`); + const tokens = s.tokens ? (s.tokens.input || 0) + (s.tokens.output || 0) : 0; + if (tokens > 0) { + parts.push(`${escape(formatTokenCount(tokens))} tok`); + } + if (!s.model && s.context_used == null && tokens === 0 && !live) { + const dir = s.worktree || s.cwd || ""; + const tail = dir.split("/").filter(Boolean).pop() || dir; + if (tail) parts.push(`${escape(tail)}`); + } + return `
${parts.join("")}
`; +} + +/** Per-tick refresh of the detail rows' busy/age text — piggybacks on + * the status-glyph ticker so busy timers count up between events. */ +function refreshSessionListDetailLive(now = Date.now()) { + if (!sessionListEl) return; + const byId = new Map(state.sessions.map((s) => [s.id, s])); + for (const el of sessionListEl.querySelectorAll(".detail-live[data-session-id]")) { + const s = byId.get(el.dataset.sessionId); + if (!s) continue; + const text = sessionDetailLiveText(s, now); + if (el.textContent !== text) el.textContent = text; + } +} + function sessionViewTitle(s) { if (!s) return ""; const title = sessionDisplayTitle(s); diff --git a/specs/0106-session-list-view-modes.md b/specs/0106-session-list-view-modes.md index 05df6989..0a40368c 100644 --- a/specs/0106-session-list-view-modes.md +++ b/specs/0106-session-list-view-modes.md @@ -38,6 +38,11 @@ Rules both modes must preserve: context gauge longest. Full mode never forces the sidebar wider and never horizontally scrolls. - Group headers and archived-disclosure rows stay one line in both modes. +- The web UI's session list shows the same detail line with the same + content and omission/fallback rules, but always on — it has no + compact/full mode pair. Its gauge may render at finer resolution than + the TUI's cell bar (a continuous fill), since the constraint being + mirrored is the information and its semantics, not the glyphs. - Selection, keyboard navigation, and scrolling operate on items, not display rows; a click anywhere within a card selects it, while gutter affordances (disclosure triangle, pin target) live on the card's first line only. @@ -67,5 +72,5 @@ lineage section set for a full/compact pair. - No third, denser-still or taller-still mode; two modes keep the toggle a binary. -- The web UI's session list is out of scope; this decision covers the TUI. +- A web-UI mode switch: the web list always shows the detail line. - The detail line is a summary, not a control surface — it hosts no buttons.