From eb87aafbccd80314e8c680047db98b044a35b2af Mon Sep 17 00:00:00 2001 From: Igor Zalutski Date: Tue, 7 Jul 2026 23:55:46 +0100 Subject: [PATCH] =?UTF-8?q?web:=20W11=20sessions=20dashboard=20=E2=80=94?= =?UTF-8?q?=20runtime=20+=20spend=20on=20list,=20richer=20viewer,=20turns?= =?UTF-8?q?=20health=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing Sessions UI (runtime-agnostic; renders flue and brain-box sessions alike): - Sessions list: Runtime badge (flue accented) + Spend column, from the session's agent_snapshot.runtime + usage. Adds agent_snapshot to the web SessionSchema and a display-only flue label to lib/runtimes (kept OUT of the create picker). - Conversation viewer: distinct rendering for agent.thinking, tool results (exec.completed AND flue's tool.result), turn.failed, and a body-spill affordance (content_ref/body_truncated). Keeps the Conversation/All level filter + live SSE tail. - Submission health: new turns panel (GET /v3/sessions/:id/turns) — per-turn state, yield_reason, timing, usage, error. Recovery stays cancel + steer + webhook redeliver (no fake retry — v3 has no turn re-drive endpoint). - Per-session spend: Spend/Tokens/Events metric cards on detail, derived defensively from the opaque usage object (flue meters at the gateway → renders '—'). - Data layer: getSessionTurns/getSessionResult wrappers + schemas; lib/usage helpers; preview mocks extended so the new surfaces render with no backend. Tier-2 note: /v3 has no streaming-parts/delta mode — tiering is the level field (internal/progress/user); 'Tier-2 fidelity' = faithful internal-level trace. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/api/client.ts | 15 ++++ web/src/api/mock.ts | 81 ++++++++++++++++-- web/src/api/schemas.ts | 36 +++++++- web/src/components/runtime-badge.tsx | 36 ++++++++ web/src/components/session-turns.tsx | 114 +++++++++++++++++++++++++ web/src/lib/runtimes.ts | 19 +++++ web/src/lib/usage.ts | 54 ++++++++++++ web/src/pages/SessionDetail.tsx | 123 +++++++++++++++++++++++++-- web/src/pages/Sessions.tsx | 17 ++++ 9 files changed, 477 insertions(+), 18 deletions(-) create mode 100644 web/src/components/runtime-badge.tsx create mode 100644 web/src/components/session-turns.tsx create mode 100644 web/src/lib/usage.ts diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c298e172..0923fbdc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -33,8 +33,10 @@ export type { SkillItem, AgentDeploy, Session, + AgentSnapshot, SessionEvent, Turn, + SessionResult, Destination, Delivery, SandboxWebhook, @@ -736,6 +738,19 @@ export const getSessionEvents = (id: string, level?: string) => S.SessionEventListSchema, ).then((r) => r.data) +// Turns — the per-submission execution records behind a session (state, timing, +// usage, error). Read-only; powers the submission-health panel. Newest first. +export const getSessionTurns = (id: string) => + apiFetch( + `/v3/sessions/${id}/turns`, + {}, + S.SessionTurnListSchema, + ).then((r) => r.data) + +// The latest turn + its result event (if the turn produced one). +export const getSessionResult = (id: string) => + apiFetch(`/v3/sessions/${id}/result`, {}, S.SessionResultSchema) + // Steer — post a user message into a session. export const sendMessage = ( id: string, diff --git a/web/src/api/mock.ts b/web/src/api/mock.ts index 991e68fb..31173625 100644 --- a/web/src/api/mock.ts +++ b/web/src/api/mock.ts @@ -657,10 +657,14 @@ const credentials = [ const sessions = [ { + // A flue (CF-native) session — meters at the gateway, so its `usage` is empty here + // (spend renders "—"); the runtime badge is accented to set it apart from brain-box. id: 'ses_a1b2c3', status: 'running', agent_id: 'agt_3kf9xz', + agent_snapshot: { runtime: 'flue', model: 'anthropic/claude-haiku-4-5' }, head: 24, + usage: {}, created_at: at(0, 1), last_turn: { state: 'running' }, sandboxes: { brain: 'sbx_a1b2c3d4e5', hands: 'sbx_f6g7h8i9j0' }, @@ -669,7 +673,9 @@ const sessions = [ id: 'ses_d4e5f6', status: 'awaiting_input', agent_id: 'agt_3kf9xz', + agent_snapshot: { runtime: 'claude', model: 'anthropic/claude-sonnet-5' }, head: 12, + usage: { cost_usd: 0.0231, input_tokens: 8200, output_tokens: 640 }, created_at: at(0, 3), last_turn: { state: 'ok', yield_reason: 'needs_input' }, }, @@ -677,7 +683,10 @@ const sessions = [ id: 'ses_g7h8i9', status: 'idle', agent_id: 'agt_7mq2aa', + agent_snapshot: { runtime: 'pi', model: 'anthropic/claude-sonnet-5' }, head: 41, + // No cost reported → the spend column falls back to a token total. + usage: { input_tokens: 15000, output_tokens: 2200 }, created_at: at(1, 2), last_turn: { state: 'ok', yield_reason: 'completed' }, }, @@ -685,7 +694,9 @@ const sessions = [ id: 'ses_j1k2l3', status: 'failed', agent_id: 'agt_3kf9xz', + agent_snapshot: { runtime: 'codex', model: 'openai/gpt-5.3-codex' }, head: 8, + usage: { cost_usd: 0.11 }, created_at: at(2, 5), last_turn: { state: 'error', yield_reason: 'error' }, }, @@ -693,7 +704,9 @@ const sessions = [ id: 'ses_m4n5o6', status: 'archived', agent_id: 'agt_7mq2aa', + agent_snapshot: { runtime: 'claude', model: 'anthropic/claude-opus-4-8' }, head: 60, + usage: { cost_usd: 1.42, input_tokens: 320000, output_tokens: 18400 }, created_at: at(4, 1), last_turn: { state: 'ok', yield_reason: 'completed' }, }, @@ -722,6 +735,17 @@ const sessionEvents = [ { id: 'evt_3', seq: 3, + type: 'agent.thinking', + level: 'progress', + actor: { type: 'agent', display: 'PR Reviewer' }, + body: { + text: 'Fetch the PR head, then read the auth middleware diff before commenting.', + }, + ts: at(0, 1), + }, + { + id: 'evt_4', + seq: 4, type: 'tool.call', level: 'progress', actor: { type: 'runtime' }, @@ -729,8 +753,21 @@ const sessionEvents = [ ts: at(0, 1), }, { - id: 'evt_4', - seq: 4, + id: 'evt_5', + seq: 5, + type: 'exec.completed', + level: 'progress', + actor: { type: 'runtime' }, + body: { + tool: 'bash', + summary: 'fetched pull/412/head → FETCH_HEAD', + duration_ms: 412, + }, + ts: at(0, 1), + }, + { + id: 'evt_6', + seq: 6, type: 'agent.message', level: 'user', actor: { type: 'agent', display: 'PR Reviewer' }, @@ -740,8 +777,8 @@ const sessionEvents = [ ts: at(0, 1), }, { - id: 'evt_5', - seq: 5, + id: 'evt_7', + seq: 7, type: 'turn.completed', level: 'user', actor: { type: 'runtime' }, @@ -750,6 +787,29 @@ const sessionEvents = [ }, ] +// Turns power the submission-health panel (GET /v3/sessions/:id/turns), newest first. +const sessionTurns = [ + { + id: 'trn_2', + state: 'ok', + yield_reason: 'needs_input', + started_at: at(0, 1), + completed_at: at(0, 1), + active_seconds: 6.4, + usage: { cost_usd: 0.0121, input_tokens: 4200, output_tokens: 310 }, + }, + { + id: 'trn_1', + state: 'error', + yield_reason: 'error', + started_at: at(0, 2), + completed_at: at(0, 2), + active_seconds: 2.1, + usage: {}, + error: { code: 'provision_infra', message: 'brain sandbox failed to start' }, + }, +] + const destinations = [ { id: 'dst_1', @@ -767,8 +827,8 @@ const deliveries = [ { id: 'dlv_1', destination: 'dst_1', - event_id: 'evt_5', - event_seq: 5, + event_id: 'evt_7', + event_seq: 7, status: 'delivered', attempts: 1, last_attempt_at: at(0, 1), @@ -778,8 +838,8 @@ const deliveries = [ { id: 'dlv_2', destination: 'dst_1', - event_id: 'evt_4', - event_seq: 4, + event_id: 'evt_6', + event_seq: 6, status: 'failed', attempts: 3, last_attempt_at: at(0, 1), @@ -876,6 +936,11 @@ const ROUTES: Array<[RegExp, Handler]> = [ [/^\/v3\/agents$/, () => ({ data: agents })], [/^\/v3\/credentials$/, () => ({ data: credentials })], [/^\/v3\/sessions\/[^/]+\/events/, () => ({ data: sessionEvents })], + [/^\/v3\/sessions\/[^/]+\/turns$/, () => ({ data: sessionTurns })], + [ + /^\/v3\/sessions\/[^/]+\/result$/, + () => ({ last_turn: sessionTurns[0], result: sessionEvents[6] }), + ], [/^\/v3\/sessions\/[^/]+\/destinations$/, () => ({ data: destinations })], [/^\/v3\/sessions\/[^/]+\/deliveries$/, () => ({ data: deliveries })], [/^\/v3\/sessions\/[^/]+$/, () => sessions[0]], diff --git a/web/src/api/schemas.ts b/web/src/api/schemas.ts index 05d8e479..661890bc 100644 --- a/web/src/api/schemas.ts +++ b/web/src/api/schemas.ts @@ -487,10 +487,23 @@ export const SlackManifestResponseSchema = z.object({ status: z.string(), }) +// The pinned effective agent tuple (design 009 §3.5) the session ran with. `runtime` +// is what distinguishes flue from the brain-box runtimes (claude/codex/pi) in read views. +export const AgentSnapshotSchema = z.object({ + runtime: z.string().nullish(), + model: z.string().nullish(), + prompt_hash: z.string().nullish(), + revision: z.union([z.string(), z.number()]).nullish(), + agent_revision_number: z.number().nullish(), + digest: z.string().nullish(), + skill_bundle_digest: z.string().nullish(), +}) + export const SessionSchema = z.object({ id: z.string(), status: z.string(), agent_id: z.string().nullable().optional(), + agent_snapshot: AgentSnapshotSchema.nullish(), credential_id: z.string().nullable().optional(), head: z.coerce.number().optional(), // current event seq; API returns it as a string ("0") last_turn: record.nullish(), @@ -525,7 +538,11 @@ export const SessionEventSchema = z.object({ level: z.string(), actor: ActorSchema.optional(), body: z.unknown().optional(), - content_ref: z.string().nullish(), // set when body spilled to blob storage + // Set together when the body spilled to blob storage (body > 32KB): the inline + // `body` is absent/partial, `content_ref` points at the blob, `body_bytes` is the size. + content_ref: z.string().nullish(), + body_truncated: z.boolean().nullish(), + body_bytes: z.number().nullish(), refs: record.nullish(), source: z.string().optional(), turn_id: z.string().nullable().optional(), @@ -544,8 +561,19 @@ export const TurnSchema = z.object({ attempt: z.number().optional(), started_at: z.string().nullable().optional(), completed_at: z.string().nullable().optional(), - usage: record.optional(), - error: z.string().nullable().optional(), + active_seconds: z.number().nullish(), + result_event_id: z.string().nullish(), + usage: record.nullish(), + error: z.unknown().nullish(), // server serializes the error as an opaque object, not a string +}) +export const SessionTurnListSchema = z.object({ + data: z.array(TurnSchema), + next_cursor: z.string().nullish(), +}) +// GET /v3/sessions/:id/result → the latest turn + its result event (if any). +export const SessionResultSchema = z.object({ + last_turn: TurnSchema.nullable(), + result: SessionEventSchema.nullable(), }) export const DestinationSchema = z.object({ @@ -583,8 +611,10 @@ export type Credential = z.infer export type SlackConnection = z.infer export type SlackManifestResponse = z.infer export type Session = z.infer +export type AgentSnapshot = z.infer export type SessionEvent = z.infer export type Turn = z.infer +export type SessionResult = z.infer export type Destination = z.infer export type Delivery = z.infer diff --git a/web/src/components/runtime-badge.tsx b/web/src/components/runtime-badge.tsx new file mode 100644 index 00000000..82dc764b --- /dev/null +++ b/web/src/components/runtime-badge.tsx @@ -0,0 +1,36 @@ +import { Bot, Cloud, type LucideIcon } from 'lucide-react' +import { runtimeLabel } from '@/lib/runtimes' +import { cn } from '@/lib/utils' + +// Runtime is a category, not a health state — so it gets a quiet, neutral pill (not a +// status tone). `flue` (the CF-native durable path) carries a subtle accent + a distinct +// icon so it reads apart from the brain-box runtimes (claude/codex/pi) at a glance. +const ICON: Record = { + flue: Cloud, +} + +export function RuntimeBadge({ + runtime, + className, +}: { + runtime: string | null | undefined + className?: string +}) { + if (!runtime) return + const Icon = ICON[runtime] ?? Bot + const isFlue = runtime === 'flue' + return ( + + + {runtimeLabel(runtime)} + + ) +} diff --git a/web/src/components/session-turns.tsx b/web/src/components/session-turns.tsx new file mode 100644 index 00000000..7ba91ffd --- /dev/null +++ b/web/src/components/session-turns.tsx @@ -0,0 +1,114 @@ +import { useQuery } from '@tanstack/react-query' +import { getSessionTurns, type Turn } from '@/api/client' +import { Panel } from '@/components/panel' +import { StatusBadge } from '@/components/status-badge' +import { ApiHint } from '@/components/api-hint' +import { formatSpend } from '@/lib/usage' + +// A turn's `error` is an opaque object (or a string); pull a one-line message defensively. +function errorMessage(error: unknown): string | null { + if (error == null) return null + if (typeof error === 'string') return error + if (typeof error === 'number' || typeof error === 'boolean') { + return String(error) + } + if (typeof error === 'object') { + const e = error as Record + const msg = e.message ?? e.error ?? e.detail + const code = typeof e.code === 'string' ? e.code : null + if (typeof msg === 'string') return code ? `${code}: ${msg}` : msg + if (code) return code + try { + return JSON.stringify(error) + } catch { + return 'error' + } + } + return 'error' +} + +// Wall-clock duration of a turn: prefer active_seconds (billed compute), else derive +// from started/completed timestamps. Returns a compact label like "4.2s" / "1m 03s". +function duration(turn: Turn): string | null { + let secs: number | null = + typeof turn.active_seconds === 'number' ? turn.active_seconds : null + if (secs == null && turn.started_at && turn.completed_at) { + const ms = Date.parse(turn.completed_at) - Date.parse(turn.started_at) + if (Number.isFinite(ms) && ms >= 0) secs = ms / 1000 + } + if (secs == null) return null + if (secs < 60) return `${secs.toFixed(1)}s` + const m = Math.floor(secs / 60) + const s = Math.round(secs % 60) + return `${m}m ${String(s).padStart(2, '0')}s` +} + +export function SessionTurns({ + sessionId, + active, +}: { + sessionId: string + active: boolean +}) { + const { data: turns, isLoading } = useQuery({ + queryKey: ['session-turns', sessionId], + queryFn: () => getSessionTurns(sessionId), + // Keep the health view current while the session is doing work; idle when settled. + refetchInterval: active ? 5000 : false, + }) + + if (!isLoading && (turns?.length ?? 0) === 0) return null // nothing to show for a session with no turns yet + + return ( + +
+

Submission health

+ +
+ + {isLoading ? ( +
Loading…
+ ) : ( +
    + {(turns ?? []).map((t) => { + const err = t.state === 'error' ? errorMessage(t.error) : null + const dur = duration(t) + const spend = formatSpend(t.usage) + return ( +
  • +
    + + + {t.id} + + {t.yield_reason ? ( + + {t.yield_reason.replace(/_/g, ' ')} + + ) : null} + + {dur ? {dur} : null} + {spend !== '—' ? {spend} : null} + +
    + {err ? ( +

    + {err} +

    + ) : null} +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/web/src/lib/runtimes.ts b/web/src/lib/runtimes.ts index 8772aca5..369b8737 100644 --- a/web/src/lib/runtimes.ts +++ b/web/src/lib/runtimes.ts @@ -86,6 +86,25 @@ export const PROVIDER_KEY_FIELDS: Record = { + claude: 'Claude', + codex: 'Codex', + pi: 'Pi', + flue: 'Flue', + hands: 'Hands', +} + +// A stable label for any runtime string (display-only — does NOT gate the create picker). +export function runtimeLabel(runtime: string | null | undefined): string { + if (!runtime) return '—' + return RUNTIME_LABELS[runtime] ?? runtime +} + export const runtimeOptions = RUNTIMES.map((r) => ({ value: r.value, label: r.label, diff --git a/web/src/lib/usage.ts b/web/src/lib/usage.ts new file mode 100644 index 00000000..b1ab36bd --- /dev/null +++ b/web/src/lib/usage.ts @@ -0,0 +1,54 @@ +// A session's / turn's `usage` is an opaque, runtime-authored object — its shape +// varies by runtime (brain-box runtimes report token counts + sometimes a cost; +// flue currently emits `{}`, with spend metered authoritatively at the gateway). +// Extract the human-meaningful bits defensively — never assume a field is present. + +export type UsageLike = Record | null | undefined + +function num(v: unknown): number | null { + return typeof v === 'number' && Number.isFinite(v) ? v : null +} + +export function hasUsage(usage: UsageLike): boolean { + return !!usage && Object.keys(usage).length > 0 +} + +// Cost in USD if the runtime reported one — several field spellings appear across runtimes. +export function usageCostUsd(usage: UsageLike): number | null { + if (!usage) return null + const u = usage + for (const k of ['cost_usd', 'total_cost_usd', 'cost', 'total_cost', 'usd']) { + const n = num(u[k]) + if (n !== null) return n + } + return null +} + +// Total tokens, summing input/output when a direct total isn't given. +export function usageTokens(usage: UsageLike): number | null { + if (!usage) return null + const u = usage + const total = num(u.total_tokens) ?? num(u.tokens) + if (total !== null) return total + const inp = num(u.input_tokens) ?? num(u.prompt_tokens) + const out = num(u.output_tokens) ?? num(u.completion_tokens) + if (inp !== null || out !== null) return (inp ?? 0) + (out ?? 0) + return null +} + +// Sub-cent per-session spend is common; show enough precision to stay non-zero. +export function formatUsd(n: number): string { + if (n === 0) return '$0' + if (n < 0.01) return `$${n.toFixed(4)}` + if (n < 1) return `$${n.toFixed(3)}` + return `$${n.toFixed(2)}` +} + +// A compact one-line spend label for a table cell / metric: "$0.0230", "1,240 tok", or "—". +export function formatSpend(usage: UsageLike): string { + const cost = usageCostUsd(usage) + if (cost !== null) return formatUsd(cost) + const tok = usageTokens(usage) + if (tok !== null) return `${tok.toLocaleString()} tok` + return '—' +} diff --git a/web/src/pages/SessionDetail.tsx b/web/src/pages/SessionDetail.tsx index b6c0d3d7..67b8da4d 100644 --- a/web/src/pages/SessionDetail.tsx +++ b/web/src/pages/SessionDetail.tsx @@ -1,7 +1,16 @@ import { useEffect, useMemo, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { ArrowLeft, Send, Wrench, CircleAlert } from 'lucide-react' +import { + ArrowLeft, + Send, + Wrench, + CircleAlert, + Brain, + CheckCircle2, + XCircle, + FileWarning, +} from 'lucide-react' import { notifyError } from '@/lib/errors' import { useHalted } from '@/hooks/useHalted' import { @@ -19,12 +28,16 @@ import { Button } from '@/components/ui/button' import { ChatTextarea } from '@/components/chat-textarea' import { Skeleton } from '@/components/ui/skeleton' import { StatusBadge } from '@/components/status-badge' +import { RuntimeBadge } from '@/components/runtime-badge' +import { MetricCard } from '@/components/metric-card' +import { SessionTurns } from '@/components/session-turns' import { EmptyState } from '@/components/empty-state' import { ConfirmDialog } from '@/components/confirm-dialog' import { SessionWebhooks } from '@/components/session-webhooks' import { ApiHint } from '@/components/api-hint' import { MessagesSquare } from 'lucide-react' import { cn } from '@/lib/utils' +import { formatSpend, usageTokens } from '@/lib/usage' // body is unknown (inline JSON ≤32KB); pull the common shapes defensively. function bodyText(ev: SessionEvent): string | null { @@ -44,6 +57,30 @@ function toolSummary(ev: SessionEvent): string { const input = typeof b.input === 'string' ? b.input : '' return input ? `${tool} · ${input}` : tool } +// A tool RESULT — brain-box emits `exec.completed`, the flue tailer emits `tool.result`; +// both land here so flue + brain-box render identically. +function isToolResult(ev: SessionEvent): boolean { + return ev.type === 'exec.completed' || ev.type === 'tool.result' +} +function toolResult(ev: SessionEvent): { text: string; isError: boolean } { + const b = (ev.body ?? {}) as Record + const tool = typeof b.tool === 'string' ? b.tool : 'tool' + const isError = b.is_error === true || b.error != null + const summary = + (typeof b.summary === 'string' && b.summary) || + (typeof b.output === 'string' && b.output) || + (typeof b.text === 'string' && b.text) || + '' + const dur = typeof b.duration_ms === 'number' ? ` · ${b.duration_ms}ms` : '' + return { text: summary ? `${tool} → ${summary}${dur}` : `${tool} →${dur}`, isError } +} +// The body spilled to blob storage (event > 32KB) — surface an affordance instead of +// rendering an empty bubble. +function truncationNote(ev: SessionEvent): string | null { + if (!ev.body_truncated && !ev.content_ref) return null + const kb = ev.body_bytes ? ` (${Math.round(ev.body_bytes / 1024)} KB)` : '' + return `Output too large to inline${kb} — stored in blob` +} function humanizeType(t: string): string { return t.replace(/[._]/g, ' ').replace(/^\w/, (c) => c.toUpperCase()) } @@ -202,11 +239,14 @@ export default function SessionDetail() {
-
+
{sessionId} + {session?.agent_snapshot?.runtime ? ( + + ) : null}

{session?.head ?? 0} events · created{' '} @@ -270,6 +310,21 @@ export default function SessionDetail() {

+ {/* Spend / usage — derived from the session's opaque usage object (no dedicated + spend endpoint). Tokens shown only when the runtime reports them (flue meters + at the gateway and reports none here). */} +
+ + + +
+ {/* Event stream */}
@@ -367,6 +422,15 @@ export default function SessionDetail() {
+ + + + {text ?? trunc} + + ) + } // Conversation messages — the signal. if (ev.type === 'user.message' || ev.type === 'agent.message') { @@ -408,7 +484,16 @@ function EventRow({ ev }: { ev: SessionEvent }) { #{ev.seq}
-

{text}

+ {text ? ( +

+ {text} +

+ ) : trunc ? ( +

+ + {trunc} +

+ ) : null} {outOfCredits && (