feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import type { LiveSessionInfo } from '../src/types.js'
|
||||
import type { LiveSessionInfo, StatusTelemetry, ClaudeStatus } from '../src/types.js'
|
||||
|
||||
/** Shape of GET /live-sessions/:id/preview. */
|
||||
export interface SessionPreview {
|
||||
@@ -44,9 +44,13 @@ export function relTime(ms: number): string {
|
||||
return `${Math.floor(s / 86400)}d`
|
||||
}
|
||||
|
||||
/** Human label for a Claude activity status. */
|
||||
/** Human label for a Claude activity status. Supports all ClaudeStatus values including 'stuck'. */
|
||||
export function statusText(s: LiveSessionInfo['status']): string {
|
||||
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
|
||||
if (s === 'working') return '⚙ working'
|
||||
if (s === 'waiting') return '⏳ waiting'
|
||||
if (s === 'idle') return '✓ idle'
|
||||
if (s === 'stuck') return '⚠ stuck'
|
||||
return '·'
|
||||
}
|
||||
|
||||
/** Display name for a session: last cwd segment, else the short id. */
|
||||
@@ -178,3 +182,86 @@ export async function fetchLiveSessions(): Promise<LiveSessionInfo[]> {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a telemetry gauge into `container` (clears first).
|
||||
*
|
||||
* Shows: context-usage bar (>80% = warning colour), $cost chip, model chip,
|
||||
* and a PR badge. When `telemetry.at` is older than `staleTtlMs` the container
|
||||
* receives the class `tg-stale` so CSS can grey it out.
|
||||
*
|
||||
* Security: all telemetry strings are set via `textContent` (SEC-H5); the PR
|
||||
* link href is only set when `url.protocol === 'https:'` (SEC-L5).
|
||||
* Zero `innerHTML` anywhere.
|
||||
*/
|
||||
export function renderTelemetryGauge(
|
||||
container: HTMLElement,
|
||||
telemetry: StatusTelemetry | null,
|
||||
staleTtlMs: number,
|
||||
): void {
|
||||
// Clear existing children
|
||||
while (container.firstChild) container.removeChild(container.firstChild)
|
||||
|
||||
if (!telemetry) {
|
||||
container.classList.remove('tg-stale')
|
||||
return
|
||||
}
|
||||
|
||||
const isStale = Date.now() - telemetry.at > staleTtlMs
|
||||
if (isStale) container.classList.add('tg-stale')
|
||||
else container.classList.remove('tg-stale')
|
||||
|
||||
// Context-usage bar
|
||||
if (telemetry.contextUsedPct !== undefined) {
|
||||
const bar = el('div', 'tg-ctx-bar')
|
||||
const fill = el('div', 'tg-ctx-fill')
|
||||
fill.style.width = `${Math.min(100, telemetry.contextUsedPct)}%`
|
||||
if (telemetry.contextUsedPct > 80) fill.classList.add('tg-ctx-warn')
|
||||
bar.append(fill, el('span', 'tg-ctx-label', `ctx ${Math.round(telemetry.contextUsedPct)}%`))
|
||||
container.append(bar)
|
||||
}
|
||||
|
||||
// Cost chip
|
||||
if (telemetry.costUsd !== undefined) {
|
||||
container.append(el('span', 'tg-cost', `$${telemetry.costUsd.toFixed(4)}`))
|
||||
}
|
||||
|
||||
// Model chip
|
||||
if (telemetry.model !== undefined) {
|
||||
container.append(el('span', 'tg-model', telemetry.model))
|
||||
}
|
||||
|
||||
// PR badge
|
||||
if (telemetry.pr !== undefined) {
|
||||
const badge = el('span', 'tg-pr')
|
||||
const link = el('a', 'tg-pr-link')
|
||||
link.textContent = `PR #${telemetry.pr.number}`
|
||||
|
||||
// SEC-L5: only set href for https URLs
|
||||
try {
|
||||
const parsed = new URL(telemetry.pr.url)
|
||||
if (parsed.protocol === 'https:') {
|
||||
link.href = telemetry.pr.url
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener noreferrer'
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL — leave href unset
|
||||
}
|
||||
|
||||
badge.append(link)
|
||||
if (telemetry.pr.reviewState !== undefined) {
|
||||
badge.append(el('span', 'tg-pr-state', telemetry.pr.reviewState))
|
||||
}
|
||||
container.append(badge)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Claude status badge into `container` (clears first).
|
||||
* 'stuck' shows the ⚠ warning symbol (A5). Uses only textContent (SEC-H5).
|
||||
*/
|
||||
export function renderStatusBadge(container: HTMLElement, status: ClaudeStatus): void {
|
||||
while (container.firstChild) container.removeChild(container.firstChild)
|
||||
container.append(el('span', `sb-badge sb-${status}`, statusText(status)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user