diff --git a/approval-gate/Cargo.lock b/approval-gate/Cargo.lock index 4f4ba04f3..dbf6a044f 100644 --- a/approval-gate/Cargo.lock +++ b/approval-gate/Cargo.lock @@ -551,8 +551,8 @@ dependencies = [ "async-trait", "clap", "globset", - "iii-helpers 0.21.2-next.1", - "iii-sdk 0.21.2-next.1", + "iii-helpers 0.21.3", + "iii-sdk 0.21.3", "jsonschema", "schemars", "serde", @@ -827,9 +827,9 @@ dependencies = [ [[package]] name = "iii-helpers" -version = "0.21.2-next.1" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030839b177b118574ca2f8c03030f33948cd353eb774127f281d200428a4918d" +checksum = "d15bd6ce4a2d13393e66a9431a7059225938e78650db1d587829d0171e0f7473" dependencies = [ "futures-util", "opentelemetry", @@ -869,14 +869,14 @@ dependencies = [ [[package]] name = "iii-sdk" -version = "0.21.2-next.1" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1837ea510df889a7f9c9d7b3432c962946674f54741de7e6ec6e8ad2534ee83c" +checksum = "c6455e06a9357696d8aa7cf7ba6d6b30ce8803036d85ea53a51de76178b867ec" dependencies = [ "async-trait", "futures-util", "hostname", - "iii-helpers 0.21.2-next.1", + "iii-helpers 0.21.3", "reqwest", "schemars", "serde", diff --git a/console/docs/timeline-span-tags.md b/console/docs/timeline-span-tags.md index b1dd8cd6a..898b5b3f6 100644 --- a/console/docs/timeline-span-tags.md +++ b/console/docs/timeline-span-tags.md @@ -1,14 +1,16 @@ -# Timeline span tags: `iii.tag.kind` / `iii.tag.display_name` +# Timeline span tags: `iii.tag.kind` / `iii.tag.display_name` / `iii.tag.hidden` A convention for marking OpenTelemetry spans as **relevant** — the spans a trace UI should treat as first-class segments (an agent turn, a sub-agent -run, a queue-dispatched job) rather than anonymous `execute ` bars. Two -span attributes carry the whole contract: +run, a queue-dispatched job) rather than anonymous `execute ` bars — +or, inversely, as **internal plumbing** a trace UI should hide by default. +Three span attributes carry the whole contract: | Attribute | Value | Meaning | |---|---|---| | `iii.tag.kind` | free-form string, dot-namespaced (`harness.turn`, `queue.process`, …) | classifies the span; its **presence** is what makes a span a relevant-span candidate | | `iii.tag.display_name` | free-form human string | overrides the span's display label wherever it renders | +| `iii.tag.hidden` | free-form family label (`harness state`, `session events`, …) | marks the span as INTERNAL: trace UIs stack tagged spans into a separate span-filter section, hidden by default; the value is the section's entry label | Nothing here deviates from OTel: producers stamp plain span attributes, either directly or via W3C baggage, and consumers read them back off the @@ -30,6 +32,25 @@ than the default verb-stripped span name — `Workflow: cleanup temp files` beats `execute workflow::step`; a display name that just repeats the function name is noise. +**Internal families (`iii.tag.hidden`) in use today**, all set as a baggage +scope around ONE outbound call (`run_with_baggage` — the smear is the +point: every span of the delivery carries the tag and hides on its own +match; an untagged descendant survives, re-attached to the hidden span's +parent): + +| Family | Producer | What it covers | +|---|---|---| +| `harness state` | harness `src/state.rs` | `state::*` turn/queue/idempotency bookkeeping | +| `session updates` | harness `clients/session.rs` | the per-stream-batch `session::update-message` writes | +| `session events` | session-manager `IiiDeliverer` | session-event fan-out to subscribers (the console's live relays) | +| `turn enqueue` | harness `src/turn_loop.rs` | the re-enqueue of the next `harness::turn` step (the queue consumer scrubs the tag at the boundary) | + +Unlike `iii.tag.kind`, there is no root/echo distinction for `iii.tag.hidden` +— every tagged span hides. Use it for call sites whose spans are plumbing +from THIS caller while the same function stays meaningful from others; for +a function that is plumbing from everywhere, prefer `trace_hidden` +registration metadata (workers/docs/sops/trace-hidden-functions.md). + --- ## 1. How tags get onto spans @@ -91,6 +112,16 @@ root. Backends and consumers must apply this rule wherever they enumerate relevant spans; a producer stamping direct attributes on one span trivially satisfies it. +**Gap spans: compare against the nearest tagged ancestor.** "Parent" in the +rule must be read as *nearest ancestor carrying the attribute*: a worker on +an SDK whose span processor drops `iii.tag.*` baggage leaves tag-less spans +in the middle of a scope, while its downstream callees re-materialize the +tags (in real traces, `execute context::assemble` carries nothing while its +`router::models::get` child repeats the sub-agent's tags). A consumer that +compares only the immediate parent misreads such echoes as fresh tag roots. +The console's implementation is `inheritedTags` in +`workers/console/web/src/pages/TracesV2/lib/spanLabel.ts`. + **Queue boundaries reset the scope.** A publisher's baggage necessarily carries its own scope's tags, and they ride the queued message; replayed verbatim they would smear the *publisher's* identity over the whole @@ -107,11 +138,23 @@ function re-stamps its own tags. The lightest consumption — per rendered span: -- **Label**: `iii.tag.display_name`, when present, wins outright over the - default (verb-stripped) span name. +- **Label**: `iii.tag.display_name` wins over the default (verb-stripped) + span name — but only where it is NEW information: a span whose nearest + display-carrying ancestor already has the same value is a baggage echo + and keeps its own name. Without this, one sub-agent turn renders its + title on every LLM call, session write, and tool span in its scope + (trace `f6292958dfe97afbd87e323d4f4541b6`: 69 of 129 spans). - **Icon/classification**: `iii.tag.kind` is checked before the raw OTel `SpanKind` when bucketing the span's icon. `queue.process` buckets with queue consumers/producers; `harness.*` buckets with function invocations. +- **Filter grouping**: a tag ROOT with no explicit function identity of its + own groups under its span NAME in the span filter, not under the function + whose baggage it inherits — and hiding a function's spans spares tag-root + descendants (they re-root instead of vanishing). This is what lets the + `trace_hidden` convention (see + [`../../docs/sops/trace-hidden-functions.md`](../../docs/sops/trace-hidden-functions.md)) + hide `harness::turn`'s queue/execute wrappers while the `harness::turn + step` segment stays visible. Untagged spans are untouched, so the convention is strictly additive. diff --git a/console/src/configuration.rs b/console/src/configuration.rs index 0866f08f8..c992e0c35 100644 --- a/console/src/configuration.rs +++ b/console/src/configuration.rs @@ -54,8 +54,32 @@ fn schema() -> Value { }) } +/// Out-of-the-box preferences seeded when the entry has never been +/// configured. +/// +/// - `views`: `view-sessions` groups traces by session and labels rows with +/// the tag message; the frontend selects it by default in browsers that +/// never made an explicit view choice (`DEFAULT_VIEW_ID` in web +/// tracesViews.ts — keep the id in sync). +/// - `spanFilters`: detail-view funnel defaults — hide the chatty +/// `harness::send` span group and the session/context bookkeeping workers. fn default_value() -> Value { - json!({ "traces": { "views": [] } }) + json!({ + "traces": { + "views": [{ + "id": "view-sessions", + "name": "sessions", + "groupBy": "iii.session.name", + "hiddenFunctions": [], + "label": { "mode": "attribute", "attribute": "iii.tag.message" }, + "filters": {} + }], + "spanFilters": { + "hiddenGroups": ["harness::send"], + "hiddenWorkers": ["context-manager", "session-manager"] + } + } + }) } /// Best-effort registration of the `console` configuration entry. Seeds the diff --git a/console/web/src/components/chat/ChatView.tsx b/console/web/src/components/chat/ChatView.tsx index 5cd23fe69..ebdeb1659 100644 --- a/console/web/src/components/chat/ChatView.tsx +++ b/console/web/src/components/chat/ChatView.tsx @@ -751,6 +751,23 @@ export function ChatView({ const ensureSession = conversationsCtx?.ensureSession + // Composer draft persistence: the live text is recorded per conversation + // (and, for server-backed sessions, saved through the debounced + // `session::set-draft`) so a page refresh restores what was typed. + // `getDraftText` already falls back to the meta-restored value — no `??` + // here, or a known-emptied draft (sent message) would resurrect the stale + // boot snapshot on switch-back. The direct read covers ctx-less mounts + // (Storybook). + const composerInitialText = conversationsCtx + ? conversationsCtx.getDraftText(conversation.id) + : conversation.draftText + const handleComposerTextChange = useCallback( + (text: string) => { + conversationsCtx?.setDraftText(conversation.id, text) + }, + [conversationsCtx, conversation.id], + ) + const handleSubmit = useCallback( async (payload: ComposerSubmitPayload) => { if (harnessBlockedRef.current) return @@ -1611,6 +1628,8 @@ export function ChatView({ onPermissionModeChange={(next) => void approvalSettings.setMode(next) } + initialText={composerInitialText} + onTextChange={handleComposerTextChange} onSubmit={handleSubmit} onStop={handleStop} queuedForEdit={backend.editQueued ? queuedForEdit : undefined} diff --git a/console/web/src/components/chat/Composer.tsx b/console/web/src/components/chat/Composer.tsx index cb4df6d7e..81928ae54 100644 --- a/console/web/src/components/chat/Composer.tsx +++ b/console/web/src/components/chat/Composer.tsx @@ -1,8 +1,12 @@ -import type { LexicalEditor } from 'lexical' +import { + $createParagraphNode, + $createTextNode, + $getRoot, + type LexicalEditor, +} from 'lexical' import { ArrowUp, Square } from 'lucide-react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PermissionModePicker } from '@/components/permissions/PermissionModePicker' -import { Button } from '@/components/ui/Button' import type { PermissionMode } from '@/lib/backend/approval-settings' import type { FunctionEntry } from '@/lib/functions' import { cn } from '@/lib/utils' @@ -26,6 +30,15 @@ export interface ComposerSubmitPayload { attachments: Attachment[] } +/** Round icon action button (send / queue / stop) at the composer's edge. */ +const actionButtonClass = cn( + 'inline-flex items-center justify-center size-8 rounded-full bg-bg text-ink', + '[html[data-theme=dark]_&]:bg-white [html[data-theme=dark]_&]:text-[#0a0a0a]', + 'hover:opacity-80 transition-opacity duration-150', + 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + 'disabled:pointer-events-none disabled:opacity-40', +) + interface ComposerProps { mode: Mode model: ModelId | null @@ -82,6 +95,18 @@ interface ComposerProps { blockedPlaceholder?: string /** Initial editor content (applied once on mount). */ initialContent?: (editor: LexicalEditor) => void + /** + * Plain-text sugar for `initialContent` (applied once on mount): seeds the + * editor AND the internal text state, so a restored draft submits without + * requiring a keystroke first. Ignored when `initialContent` is given. + */ + initialText?: string + /** + * Live text of the user's draft, fired on every editor change EXCEPT while + * a queued message is being browsed/edited (that text is not the draft). + * Powers the per-session draft persistence. + */ + onTextChange?: (text: string) => void /** Initial attachment chips (applied once on mount). */ initialAttachments?: Attachment[] functionEntries?: FunctionEntry[] @@ -131,6 +156,8 @@ export function Composer({ blocked, blockedPlaceholder = 'chat unavailable…', initialContent, + initialText, + onTextChange, initialAttachments, functionEntries, queuedForEdit, @@ -141,14 +168,41 @@ export function Composer({ initialAttachments ?? [], ) const [clearToken, setClearToken] = useState(0) - const textRef = useRef('') + const textRef = useRef(initialContent ? '' : (initialText ?? '')) + /* Boolean mirror of "the editor holds text": the action button swaps on + the empty↔non-empty transition, and state updates for an unchanged + boolean bail out — so plain typing still never re-renders the tree. */ + const [hasText, setHasText] = useState( + () => textRef.current.trim().length > 0, + ) + + // One-shot mount initializer: seed the editor with the restored draft text. + // Runs inside Lexical's initial-state update, so $-functions apply directly. + // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only initializer, matching LexicalShell's one-shot initialConfig semantics. + const resolvedInitialContent = useMemo(() => { + if (initialContent) return initialContent + const text = initialText + if (!text) return undefined + return () => { + const root = $getRoot() + root.clear() + const paragraph = $createParagraphNode() + paragraph.append($createTextNode(text)) + root.append(paragraph) + } + }, []) // ↑/↓ browse the queued messages for editing. `browseId` is the message the // editor currently holds (null = a live draft). Navigation is non-destructive // — the message is removed from the queue only when the edit is submitted. + // The ref mirror gates `onTextChange` synchronously: `setBrowse` runs before + // the loaded text echoes back through the editor's change event, so browsed + // queue text is never reported as the live draft. const [browseId, setBrowseId] = useState(null) + const browseIdRef = useRef(null) const setBrowse = useCallback( (id: string | null) => { + browseIdRef.current = id setBrowseId(id) onBrowseChange?.(id) }, @@ -177,6 +231,7 @@ export function Composer({ setBrowse(result.target.id) setAttachments(result.target.attachments) textRef.current = result.target.text + setHasText(result.target.text.trim().length > 0) return result.target.text }, [queuedForEdit, browseId, setBrowse], @@ -201,9 +256,21 @@ export function Composer({ onSubmit({ text, attachments }) } textRef.current = '' + setHasText(false) + // The submitted text is no longer a draft; report the clear even if the + // editor-clear update below is tag-filtered by the change plugin. + onTextChange?.('') setAttachments([]) setClearToken((t) => t + 1) - }, [inputDisabled, attachments, onSubmit, browseId, onEditQueued, setBrowse]) + }, [ + inputDisabled, + attachments, + onSubmit, + browseId, + onEditQueued, + setBrowse, + onTextChange, + ]) const handleAttach = useCallback((next: Attachment[]) => { setAttachments((current) => [...current, ...next]) @@ -231,6 +298,8 @@ export function Composer({ { textRef.current = text + setHasText(text.trim().length > 0) + if (browseIdRef.current === null) onTextChange?.(text) }} onSubmit={handleSubmit} clearToken={clearToken} @@ -244,7 +313,7 @@ export function Composer({ : 'send a message…' } disabled={inputDisabled} - initialContent={initialContent} + initialContent={resolvedInitialContent} functionEntries={functionEntries} workingDir={workingDir} onHistoryNav={onEditQueued ? handleHistoryNav : undefined} @@ -282,31 +351,17 @@ export function Composer({ />
- {isStreaming && queueWhileStreaming ? ( - - ) : null} - {isStreaming ? ( + {/* ONE action button. Mid-stream the slot shows Stop, but the moment + the composer holds queueable content (text or attachments) it + flips to send — the editor advertises "queue a message…", and the + click must queue it, not kill the turn. */} + {isStreaming && + !(queueWhileStreaming && (hasText || attachments.length > 0)) ? ( @@ -315,14 +370,8 @@ export function Composer({ type="button" onClick={handleSubmit} disabled={blocked} - aria-label="send message" - className={cn( - 'inline-flex items-center justify-center size-8 rounded-full bg-bg text-ink', - '[html[data-theme=dark]_&]:bg-white [html[data-theme=dark]_&]:text-[#0a0a0a]', - 'hover:opacity-80 transition-opacity duration-150', - 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', - 'disabled:pointer-events-none disabled:opacity-40', - )} + aria-label={isStreaming ? 'queue message' : 'send message'} + className={actionButtonClass} > diff --git a/console/web/src/hooks/use-conversations.test.ts b/console/web/src/hooks/use-conversations.test.ts index 9bdebdc41..3d43cbeb8 100644 --- a/console/web/src/hooks/use-conversations.test.ts +++ b/console/web/src/hooks/use-conversations.test.ts @@ -89,6 +89,22 @@ describe('applyCatalogModelFallback', () => { }) describe('mergeConversationMeta', () => { + it('restores the parked composer draft from SessionMeta.draft', () => { + const next = mergeConversationMeta( + undefined, + sessionMeta({ draft: 'half-typed thought' }), + ) + expect(next.draftText).toBe('half-typed thought') + + // Absent / empty server drafts map to "nothing to restore". + expect(mergeConversationMeta(undefined, sessionMeta({})).draftText).toBe( + undefined, + ) + expect( + mergeConversationMeta(undefined, sessionMeta({ draft: '' })).draftText, + ).toBe(undefined) + }) + it('repairs a stale idle row from authoritative session metadata', () => { const existing = conversation({ status: 'idle', diff --git a/console/web/src/hooks/use-conversations.ts b/console/web/src/hooks/use-conversations.ts index b3d45dc99..dbe848251 100644 --- a/console/web/src/hooks/use-conversations.ts +++ b/console/web/src/hooks/use-conversations.ts @@ -33,6 +33,7 @@ import { fetchTranscript, getSession, listSessions, + setSessionDraft, setSessionMeta, } from '@/lib/sessions/api' import { @@ -66,6 +67,10 @@ function uid(): string { return Math.random().toString(36).slice(2) + Date.now().toString(36) } +/** Composer-draft save cadence (`session::set-draft` is event-silent, so the + * only costs are the RPC and one JSONL append per flush). */ +const DRAFT_SAVE_DEBOUNCE_MS = 500 + /** Draft ids double as engine session ids — minted with the console prefix. */ function newConversationId(): string { if ( @@ -147,6 +152,10 @@ function conversationFromMeta(meta: SessionMeta): Conversation { md.spawned_by === 'trigger' || md.spawned_by === 'agent' ? md.spawned_by : undefined, + draftText: + typeof meta.draft === 'string' && meta.draft.length > 0 + ? meta.draft + : undefined, messages: [], status: meta.status, statusReason: meta.status_reason, @@ -267,6 +276,19 @@ export interface ConversationsApi { * send (idempotent). `titleHint` seeds the session title from the prompt. */ ensureSession: (id: string, titleHint?: string) => Promise + /** + * Record the composer's live text for a conversation. Kept in a ref (no + * re-render per keystroke) and, for server-backed sessions, persisted via + * the debounced event-silent `session::set-draft` so a page refresh + * restores what the user was typing. + */ + setDraftText: (id: string, text: string) => void + /** + * The composer text to seed when (re)opening a conversation: what this tab + * last recorded via `setDraftText`, else the server-restored + * `SessionMeta.draft`. `undefined` when there is nothing to restore. + */ + getDraftText: (id: string) => string | undefined } /** @@ -333,6 +355,22 @@ export function useConversations( upserts: HydrationUpsert[] } | null>(null) + /* ── Composer drafts (SessionMeta.draft) ────────────────────────────── + The live editor text lives in refs — one map entry per conversation — + so keystrokes never re-render the conversation tree. Server persistence + is debounced through the event-silent `session::set-draft` (see + `setDraftText` below); reads fall back to the meta-restored + `conversation.draftText`, so the in-tab value (which knows about sends + and edits) always wins over the boot snapshot. */ + const draftTextsRef = useRef(new Map()) + const lastSavedDraftRef = useRef(new Map()) + const pendingDraftRef = useRef<{ id: string; text: string } | null>(null) + const draftTimerRef = useRef | null>(null) + /** Per-session tail of the in-flight `session::set-draft` writes: saves + chain so an older save can never land after (and clobber) a newer one + — the case that matters is the post-send CLEAR racing a stale save. */ + const draftSaveChainRef = useRef(new Map>()) + const patchConversation = useCallback( (id: string, patch: (c: Conversation) => Conversation) => { setConversations((list) => list.map((c) => (c.id === id ? patch(c) : c))) @@ -694,6 +732,9 @@ export function useConversations( const conv = conversations.find((c) => c.id === id) setConversations((list) => list.filter((c) => c.id !== id)) revisionsRef.current.delete(id) + draftTextsRef.current.delete(id) + lastSavedDraftRef.current.delete(id) + if (pendingDraftRef.current?.id === id) pendingDraftRef.current = null setActiveId((current) => (current === id ? null : current)) // Closing the conversation orphans any worktree claim this console // flow made for it; release best-effort (no-op for other claims). @@ -834,6 +875,78 @@ export function useConversations( [serverEnabled, conversations, patchConversation], ) + /* Live mirror for the draft callbacks: they fire from debounce timers and + editor events, where a stale `conversations` closure would misclassify a + just-materialised session as still-local. */ + const conversationsRef = useRef(conversations) + conversationsRef.current = conversations + + const flushDraft = useCallback(() => { + if (draftTimerRef.current) { + clearTimeout(draftTimerRef.current) + draftTimerRef.current = null + } + const pending = pendingDraftRef.current + pendingDraftRef.current = null + if (!pending) return + if (lastSavedDraftRef.current.get(pending.id) === pending.text) return + const chain = draftSaveChainRef.current + const tail = (chain.get(pending.id) ?? Promise.resolve()) + .then(async () => { + // Re-check under the chain: an earlier link may have saved this very + // value already. The saved-marker moves only AFTER the RPC resolves — + // a failed save stays eligible for retry on the next flush. + if (lastSavedDraftRef.current.get(pending.id) === pending.text) return + await setSessionDraft(pending.id, pending.text || null) + lastSavedDraftRef.current.set(pending.id, pending.text) + }) + .catch((err) => { + if (import.meta.env.DEV) { + console.warn('[conversations] set-draft failed', err) + } + }) + .finally(() => { + if (chain.get(pending.id) === tail) chain.delete(pending.id) + }) + chain.set(pending.id, tail) + }, []) + + const setDraftText = useCallback( + (id: string, text: string) => { + draftTextsRef.current.set(id, text) + if (!serverEnabled) return + const conv = conversationsRef.current.find((c) => c.id === id) + // Local drafts have no session yet; their text still lives in the ref + // map so in-tab switches keep it. + if (!conv || conv.draft) return + if (pendingDraftRef.current && pendingDraftRef.current.id !== id) { + flushDraft() + } + pendingDraftRef.current = { id, text } + if (draftTimerRef.current) clearTimeout(draftTimerRef.current) + draftTimerRef.current = setTimeout(flushDraft, DRAFT_SAVE_DEBOUNCE_MS) + }, + [serverEnabled, flushDraft], + ) + + const getDraftText = useCallback((id: string): string | undefined => { + const live = draftTextsRef.current.get(id) + if (live !== undefined) return live || undefined + return conversationsRef.current.find((c) => c.id === id)?.draftText + }, []) + + /* A hidden tab may be a refresh in progress — flush the pending save so + the debounce window doesn't swallow the last keystrokes. */ + useEffect(() => { + if (!serverEnabled || typeof document === 'undefined') return + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') flushDraft() + } + document.addEventListener('visibilitychange', onVisibilityChange) + return () => + document.removeEventListener('visibilitychange', onVisibilityChange) + }, [serverEnabled, flushDraft]) + return { conversations, activeId, @@ -850,6 +963,8 @@ export function useConversations( updateMessage, compactConversation, ensureSession, + setDraftText, + getDraftText, } } diff --git a/console/web/src/lib/sessions/api.ts b/console/web/src/lib/sessions/api.ts index b5f72c5f1..304d83525 100644 --- a/console/web/src/lib/sessions/api.ts +++ b/console/web/src/lib/sessions/api.ts @@ -60,6 +60,22 @@ export async function setSessionMeta(input: { return client.trigger('session::set-meta', input) } +/** + * Park (or clear, with `null`/empty text) the session's unsent composer + * input. Event-silent and `updated_at`-neutral server-side, so it is safe + * at keystroke cadence; reads back as `SessionMeta.draft`. + */ +export async function setSessionDraft( + sessionId: string, + draft: string | null, +): Promise { + const client = await getIiiClient() + await client.trigger('session::set-draft', { + session_id: sessionId, + ...(draft ? { draft } : {}), + }) +} + export async function deleteSession( sessionId: string, ): Promise<{ deleted: boolean }> { diff --git a/console/web/src/lib/sessions/types.ts b/console/web/src/lib/sessions/types.ts index 5958febde..0cb52dc64 100644 --- a/console/web/src/lib/sessions/types.ts +++ b/console/web/src/lib/sessions/types.ts @@ -61,6 +61,11 @@ export type SessionMeta = { /** App-defined; the console stores { surface, model, mode, title_manual }. */ metadata?: Record forked_from?: string + /** + * Unsent composer input parked via `session::set-draft` (event-silent, + * never bumps `updated_at`); absent when nothing is parked. + */ + draft?: string created_at: number updated_at: number message_count: number diff --git a/console/web/src/lib/storage.ts b/console/web/src/lib/storage.ts index ebbb81b9b..bc1bd6a58 100644 --- a/console/web/src/lib/storage.ts +++ b/console/web/src/lib/storage.ts @@ -66,24 +66,55 @@ export function saveDefaultAllowlist(list: string[]): void { } const TRACES_ACTIVE_VIEW_KEY = 'iii-traces-active-view' +/** Stored when the user explicitly picks "all traces", so an absent key + * (fresh browser) stays distinguishable and can default to the seeded view. + * Real ids never collide: they are `view-*`. */ +const TRACES_NO_VIEW = 'none' /** * Per-browser pointer to the active traces view. The views themselves are * server-side (`console` configuration entry); only the selection is local * so two tabs can look at different views without fighting. + * + * `undefined` = no choice recorded yet (callers may pick a default); + * `null` = the user explicitly chose "all traces". */ -export function loadActiveTracesViewId(): string | null { +export function loadActiveTracesViewId(): string | null | undefined { try { - return localStorage.getItem(TRACES_ACTIVE_VIEW_KEY) + const raw = localStorage.getItem(TRACES_ACTIVE_VIEW_KEY) + if (raw === null) return undefined + return raw === TRACES_NO_VIEW ? null : raw } catch { - return null + return undefined } } export function saveActiveTracesViewId(id: string | null): void { try { - if (id) localStorage.setItem(TRACES_ACTIVE_VIEW_KEY, id) - else localStorage.removeItem(TRACES_ACTIVE_VIEW_KEY) + localStorage.setItem(TRACES_ACTIVE_VIEW_KEY, id ?? TRACES_NO_VIEW) + } catch { + /* best-effort */ + } +} + +const TRACES_FOLLOW_TURNS_KEY = 'iii-traces-follow-turns' + +/** + * Per-browser "follow" toggle on the traces masthead: when on, the surface + * auto-opens the trace of the active chat's live turn (user interactions + * only — sub-agent turns never steal the view). + */ +export function loadFollowTurns(): boolean { + try { + return localStorage.getItem(TRACES_FOLLOW_TURNS_KEY) === '1' + } catch { + return false + } +} + +export function saveFollowTurns(on: boolean): void { + try { + localStorage.setItem(TRACES_FOLLOW_TURNS_KEY, on ? '1' : '0') } catch { /* best-effort */ } diff --git a/console/web/src/lib/trace-hidden-functions.ts b/console/web/src/lib/trace-hidden-functions.ts new file mode 100644 index 000000000..712d81a73 --- /dev/null +++ b/console/web/src/lib/trace-hidden-functions.ts @@ -0,0 +1,55 @@ +/** + * Functions whose producers marked them `trace_hidden: true` in their + * registration metadata (see workers/docs/sops/trace-hidden-functions.md): + * dispatch machinery and per-turn bookkeeping (session/context managers, + * `harness::turn`) whose spans would drown trace timelines. The traces + * page hides these span groups BY DEFAULT; users can unhide them from the + * funnel menu, and that override persists (`traces.spanFilters.shownGroups`). + * + * Separate from `functions-catalog.ts` (the `@`-mention autocomplete): + * this fetch passes `include_internal: true` — internal plumbing is + * exactly what tends to be tagged — while the mention catalog must keep + * internal functions out. + */ + +import { getIiiClient } from '@/lib/iii-client' + +const FUNCTIONS_LIST_RPC = 'engine::functions::list' + +export const EMPTY_HIDDEN_IDS: ReadonlySet = new Set() + +function parseTraceHiddenIds(res: unknown): ReadonlySet { + if (!res || typeof res !== 'object') return EMPTY_HIDDEN_IDS + const rows = (res as Record).functions + if (!Array.isArray(rows)) return EMPTY_HIDDEN_IDS + + const out = new Set() + for (const raw of rows) { + if (!raw || typeof raw !== 'object') continue + const o = raw as Record + const id = typeof o.function_id === 'string' ? o.function_id : '' + if (!id) continue + const metadata = o.metadata + if (!metadata || typeof metadata !== 'object') continue + if ((metadata as Record).trace_hidden === true) out.add(id) + } + return out +} + +/** + * Function ids registered with `trace_hidden: true`. Resolves to the empty + * set on any failure — the traces page then simply hides nothing by default. + */ +export async function fetchTraceHiddenFunctionIds(): Promise< + ReadonlySet +> { + try { + const client = await getIiiClient() + const res = await client.trigger(FUNCTIONS_LIST_RPC, { + include_internal: true, + }) + return parseTraceHiddenIds(res) + } catch { + return EMPTY_HIDDEN_IDS + } +} diff --git a/console/web/src/pages/TracesV2/components/GroupedTraceList.tsx b/console/web/src/pages/TracesV2/components/GroupedTraceList.tsx index e0056144e..44f3729c3 100644 --- a/console/web/src/pages/TracesV2/components/GroupedTraceList.tsx +++ b/console/web/src/pages/TracesV2/components/GroupedTraceList.tsx @@ -40,6 +40,8 @@ interface GroupedTraceListProps { selectedTraceId: string | null onSelectTrace: (traceId: string) => void onHideFunction?: (functionId: string) => void + /** accordion body rendered beneath the selected member row */ + expandedContent?: React.ReactNode } export function GroupedTraceList({ @@ -50,6 +52,7 @@ export function GroupedTraceList({ selectedTraceId, onSelectTrace, onHideFunction, + expandedContent, }: GroupedTraceListProps) { const { groups, isLoading, unavailable } = useTraceGroups({ groupBy: attribute, @@ -128,6 +131,7 @@ export function GroupedTraceList({ selectedTraceId={selectedTraceId} onSelectTrace={onSelectTrace} onHideFunction={onHideFunction} + expandedContent={expandedContent} /> )}
@@ -201,6 +205,7 @@ interface GroupMembersProps { selectedTraceId: string | null onSelectTrace: (traceId: string) => void onHideFunction?: (functionId: string) => void + expandedContent?: React.ReactNode } function GroupMembers({ @@ -212,6 +217,7 @@ function GroupMembers({ selectedTraceId, onSelectTrace, onHideFunction, + expandedContent, }: GroupMembersProps) { // Keyed by group identity + member-count so live growth refetches, while // the id list itself rides in via closure (it can be hundreds of entries). @@ -261,16 +267,18 @@ function GroupMembers({ return (
{rows.map((trace) => ( - onSelectTrace(trace.traceId)} - onAnimationEnd={() => {}} - label={label} - onHideFunction={onHideFunction} - /> +
+ onSelectTrace(trace.traceId)} + onAnimationEnd={() => {}} + label={label} + onHideFunction={onHideFunction} + /> + {selectedTraceId === trace.traceId && expandedContent} +
))}
) diff --git a/console/web/src/pages/TracesV2/components/TraceDetailSkeleton.tsx b/console/web/src/pages/TracesV2/components/TraceDetailSkeleton.tsx new file mode 100644 index 000000000..c9aa0b8b7 --- /dev/null +++ b/console/web/src/pages/TracesV2/components/TraceDetailSkeleton.tsx @@ -0,0 +1,167 @@ +import { ChevronRight, Clock, Layers, X } from 'lucide-react' +import { Button } from '@/components/ui/Button' +import { Skeleton } from '@/components/ui/Skeleton' + +/** + * Loading placeholder for the trace detail — mirrors the real composition + * (TraceHeader → view switcher → lane timeline → collapsed WorkerBreakdown) + * block-for-block with the same paddings, borders, and row heights, so the + * loaded detail resolves in place without a layout jump. Static chrome + * (icons, section labels, chip frames) renders for real; only the + * data-dependent values pulse. + */ + +/** the fake lane cascade: [left%, width%] per 22px-pitch line, root first */ +const TIMELINE_BARS: ReadonlyArray = [ + [0, 97], + [2, 41], + [5, 24], + [31, 11], + [45, 26], + [48, 13], + [63, 7], + [74, 22], +] + +interface TraceDetailSkeletonProps { + /** wired to the real close affordance so a slow load can be backed out of */ + onClose: () => void +} + +export function TraceDetailSkeleton({ onClose }: TraceDetailSkeletonProps) { + return ( +
+ {/* ── TraceHeader ── */} +
+
+ + + + + {/* bg-rule where the skeleton sits directly on the panel — + the default bg-panel tone vanishes there */} + + + +
+ +
+ + + + + + + + + + + + + +
+ + {/* worker share bar + names */} +
+
+ + + + + +
+
+ {(['wk-0', 'wk-1', 'wk-2'] as const).map((k) => ( + + + + + ))} +
+
+ + {/* critical-path breadcrumb */} +
+ {(['cp-0', 'cp-1', 'cp-2', 'cp-3'] as const).map((k, i) => ( + + {i > 0 && ( + + )} + + + + + ))} +
+
+ + {/* ── view switcher ── */} +
+ + + + + + + + +
+ + {/* ── lane timeline (ruler + hierarchical bar cascade) — content-sized, + like the real canvas (fitContent) ── */} +
+
+ {([0, 25, 50, 75] as const).map((pct) => ( + + ))} + +
+
+ {TIMELINE_BARS.map(([left, width], line) => ( + + ))} +
+
+ + {/* ── collapsed workers footer ── */} +
+
+ + + workers + + + {(['p50', 'p95', 'p99'] as const).map((p) => ( + + {p} + + ))} + +
+
+
+ ) +} diff --git a/console/web/src/pages/TracesV2/components/TraceListRow.tsx b/console/web/src/pages/TracesV2/components/TraceListRow.tsx index 2a942a73a..76377527c 100644 --- a/console/web/src/pages/TracesV2/components/TraceListRow.tsx +++ b/console/web/src/pages/TracesV2/components/TraceListRow.tsx @@ -1,4 +1,4 @@ -import { EyeOff, Timer, Zap } from 'lucide-react' +import { ChevronRight, EyeOff, Timer, Zap } from 'lucide-react' import { StatusDot } from '@/components/ui/StatusDot' import { cn } from '@/lib/utils' import type { TraceListItem } from '../hooks/useTraceData' @@ -67,6 +67,7 @@ export function TraceListRow({ type="button" onClick={onSelect} onAnimationEnd={onAnimationEnd} + aria-expanded={isSelected} className={cn( 'group/row w-full px-4 py-3 border-b border-rule-2 text-left transition-colors', isSelected ? 'bg-panel border-l-2 border-l-accent' : 'hover:bg-panel', @@ -74,6 +75,12 @@ export function TraceListRow({ )} >
+