diff --git a/console/web/src/components/chat/ChatView.tsx b/console/web/src/components/chat/ChatView.tsx index 891c74f91..9bfaec524 100644 --- a/console/web/src/components/chat/ChatView.tsx +++ b/console/web/src/components/chat/ChatView.tsx @@ -143,6 +143,12 @@ export function ChatView({ onCompactConversation, }: ChatViewProps) { const [isStreaming, setIsStreaming] = useState(false) + // Pre-content phase of this tab's in-flight send, for the thinking + // shimmer's detail line: submit → harness::send ack → turn-started. + // Null once content streams (or for turns this tab didn't start). + const [turnPhase, setTurnPhase] = useState< + 'sending' | 'accepted' | 'merged' | 'started' | null + >(null) const [thinkingLevel, setThinkingLevel] = useState( DEFAULT_THINKING_LEVEL, ) @@ -949,6 +955,7 @@ export function ChatView({ const controller = new AbortController() abortRef.current = controller setIsStreaming(true) + setTurnPhase('sending') let thoughtId: string | null = null let thoughtBuffer = '' @@ -1131,6 +1138,7 @@ export function ChatView({ }) } setIsStreaming(false) + setTurnPhase(null) assistantId = null assistantBuffer = '' break @@ -1154,6 +1162,11 @@ export function ChatView({ announcer.announce(compactionContent) break } + case 'turn-status': { + // `queued` renders in the queued-messages strip, not the shimmer. + setTurnPhase(event.phase === 'queued' ? null : event.phase) + break + } case 'stop-reason': { let noticeContent = formatStopReason(event.reason, event.message) if (event.partialResultAvailable) { @@ -1183,6 +1196,8 @@ export function ChatView({ event.kind === 'thought-start' ) { setIsStreaming(true) + // Content arrived — the pre-content phase line is over. + setTurnPhase(null) } } } catch (err) { @@ -1214,6 +1229,7 @@ export function ChatView({ onPatchMessage(conversationId, assistantId, { streaming: false }) } setIsStreaming(false) + setTurnPhase(null) abortRef.current = null } }, @@ -1274,6 +1290,26 @@ export function ChatView({ return false })() + // Pre-content phase text for the shimmer. Only trusted while the transcript + // still ends at the user's message — on the real backend content arrives via + // session events (not stream events), so once anything streamed the phase is + // stale and mid-turn gaps fall back to the model line instead. + const phaseDetail = (() => { + if (!turnPhase) return null + const last = conversation.messages[conversation.messages.length - 1] ?? null + if (last && last.role !== 'user') return null + switch (turnPhase) { + case 'sending': + return 'sending…' + case 'accepted': + return 'queued — waiting to start…' + case 'merged': + return 'added to the running turn…' + case 'started': + return null + } + })() + const isDock = density === 'dock' const headerPad = isDock ? 'px-4' : 'px-9' const footerPad = isDock ? 'px-4 pb-4 pt-2' : 'px-9 pb-6 pt-2' @@ -1485,9 +1521,8 @@ export function ChatView({ thinkingDetail={ conversation.status === 'working' && conversation.statusReason ? conversation.statusReason - : effectiveModel - ? `dispatching ${effectiveModel}` - : undefined + : (phaseDetail ?? + (effectiveModel ? `dispatching ${effectiveModel}` : undefined)) } density={density} onResolveApproval={resolveApproval} diff --git a/console/web/src/components/ui/Select.tsx b/console/web/src/components/ui/Select.tsx index 8f638426c..661b3ea15 100644 --- a/console/web/src/components/ui/Select.tsx +++ b/console/web/src/components/ui/Select.tsx @@ -5,6 +5,8 @@ import { cn } from '@/lib/utils' interface SelectOption { value: T label: string + /** Optional hover tooltip on the option row. */ + title?: string } interface SelectGroup { @@ -189,6 +191,7 @@ export function Select({ key={opt.value} value={opt.value} label={opt.label} + title={opt.title} /> ))} @@ -198,6 +201,7 @@ export function Select({ key={opt.value} value={opt.value} label={opt.label} + title={opt.title} /> ))} @@ -223,12 +227,14 @@ export function Select({ interface SelectItemProps { value: string label: string + title?: string } -function SelectItem({ value, label }: SelectItemProps) { +function SelectItem({ value, label, title }: SelectItemProps) { return ( push({ kind: 'turn-completed', event }), + onStarted: (event) => push({ kind: 'turn-started', event }), }) const stopApprovalEvents = opts?.approvalEventsExternallyManaged ? () => {} @@ -393,13 +394,15 @@ async function* realStream( messageId, opts, ) - void sendTurn(client, sendRequest).catch((err) => { - kickoffError = err instanceof Error ? err : new Error(String(err)) - if (import.meta.env.DEV) { - console.warn('[real-backend] harness::send failed', err) - } - wake() - }) + void sendTurn(client, sendRequest) + .then((response) => push({ kind: 'send-resolved', response })) + .catch((err) => { + kickoffError = err instanceof Error ? err : new Error(String(err)) + if (import.meta.env.DEV) { + console.warn('[real-backend] harness::send failed', err) + } + wake() + }) while (true) { if (signal?.aborted) return diff --git a/console/web/src/lib/backend/translate.test.ts b/console/web/src/lib/backend/translate.test.ts index 587a7554b..dfab0b545 100644 --- a/console/web/src/lib/backend/translate.test.ts +++ b/console/web/src/lib/backend/translate.test.ts @@ -4,6 +4,7 @@ import type { PendingResolvedEvent, TurnCompletedEvent, } from '@/types/iii-agent-event' +import type { HarnessSendResponse } from './harness-send' import { isTerminalSource, type TurnSourceEvent, @@ -239,3 +240,57 @@ describe('translateTurnSource — turn-completed', () => { ).toBe(false) }) }) + +describe('translateTurnSource — pre-content turn status', () => { + function sendResolved( + over: Partial = {}, + ): TurnSourceEvent { + return { + kind: 'send-resolved', + response: { + session_id: 'sess-a', + turn_id: 't-1', + accepted: true, + ...over, + }, + } + } + + it('maps a plain accepted send to phase accepted', () => { + expect(translateTurnSource(sendResolved())).toEqual([ + { kind: 'turn-status', phase: 'accepted' }, + ]) + }) + + it('maps a merged send to phase merged', () => { + expect(translateTurnSource(sendResolved({ merged: true }))).toEqual([ + { kind: 'turn-status', phase: 'merged' }, + ]) + }) + + it('maps a queued send to phase queued, even when also flagged merged', () => { + expect( + translateTurnSource(sendResolved({ queued: true, merged: true })), + ).toEqual([{ kind: 'turn-status', phase: 'queued' }]) + }) + + it('maps turn-started to phase started', () => { + const event: TurnSourceEvent = { + kind: 'turn-started', + event: { session_id: 'sess-a', turn_id: 't-1', timestamp: 0 }, + } + expect(translateTurnSource(event)).toEqual([ + { kind: 'turn-status', phase: 'started' }, + ]) + }) + + it('is not terminal for send-resolved or turn-started', () => { + expect(isTerminalSource(sendResolved())).toBe(false) + expect( + isTerminalSource({ + kind: 'turn-started', + event: { session_id: 'sess-a', turn_id: 't-1', timestamp: 0 }, + }), + ).toBe(false) + }) +}) diff --git a/console/web/src/lib/backend/translate.ts b/console/web/src/lib/backend/translate.ts index 51b38ac7b..c9208d755 100644 --- a/console/web/src/lib/backend/translate.ts +++ b/console/web/src/lib/backend/translate.ts @@ -11,6 +11,8 @@ * - `approval::pending-resolved` → `fcall-approval-cleared` * - `harness::turn-completed` → `assistant-end` (+ `stop-reason` when the * turn failed / was cancelled) + * - `harness::send` response → `turn-status` (accepted/merged/queued) + * - `harness::turn-started` → `turn-status { phase: 'started' }` * * The translator is stateless: the new gate triggers are already discrete * create/resolve events, so no list-diffing (the old `turn_state_changed` @@ -21,7 +23,9 @@ import type { PendingApprovalRecord, PendingResolvedEvent, TurnCompletedEvent, + TurnStartedEvent, } from '@/types/iii-agent-event' +import type { HarnessSendResponse } from './harness-send' import type { StreamEvent } from './types' /** The discriminated union of trigger events `realStream` feeds the translator. */ @@ -29,6 +33,8 @@ export type TurnSourceEvent = | { kind: 'approval-created'; record: PendingApprovalRecord } | { kind: 'approval-resolved'; event: PendingResolvedEvent } | { kind: 'turn-completed'; event: TurnCompletedEvent } + | { kind: 'turn-started'; event: TurnStartedEvent } + | { kind: 'send-resolved'; response: HarnessSendResponse } /** True once this event ends the turn (the generator should stop after it). */ export function isTerminalSource(event: TurnSourceEvent): boolean { @@ -79,6 +85,23 @@ export function translateTurnSource(event: TurnSourceEvent): StreamEvent[] { case 'turn-completed': return translateTurnCompleted(event.event) + + case 'turn-started': + return [{ kind: 'turn-status', phase: 'started' }] + + case 'send-resolved': { + const { response } = event + return [ + { + kind: 'turn-status', + phase: response.queued + ? 'queued' + : response.merged + ? 'merged' + : 'accepted', + }, + ] + } } } diff --git a/console/web/src/lib/backend/types.ts b/console/web/src/lib/backend/types.ts index b8a2a304f..bb629d916 100644 --- a/console/web/src/lib/backend/types.ts +++ b/console/web/src/lib/backend/types.ts @@ -103,6 +103,17 @@ export type StreamEvent = /** The assistant output above is preserved evidence, not a successful result. */ partialResultAvailable?: boolean } + | { + /** + * Pre-content turn lifecycle, for the thinking shimmer's detail line. + * `accepted` = harness::send seeded a turn (step-0 sits on the durable + * queue); `merged` = folded into an in-flight turn; `queued` = parked + * in the server-side queue (the queued strip owns that surface); + * `started` = harness::turn-started fired, the loop is running. + */ + kind: 'turn-status' + phase: 'accepted' | 'merged' | 'queued' | 'started' + } export interface ChatStreamOptions { signal?: AbortSignal diff --git a/console/web/src/stories/playground/EventLog.tsx b/console/web/src/stories/playground/EventLog.tsx index 699d70a34..48199d537 100644 --- a/console/web/src/stories/playground/EventLog.tsx +++ b/console/web/src/stories/playground/EventLog.tsx @@ -185,6 +185,8 @@ function toneFor(kind: StreamEvent['kind']): string { return 'text-warn' case 'stop-reason': return 'text-warn' + case 'turn-status': + return 'text-ink-faint' } } diff --git a/harness/src/turn_loop.rs b/harness/src/turn_loop.rs index 51524b010..425f9cb47 100644 --- a/harness/src/turn_loop.rs +++ b/harness/src/turn_loop.rs @@ -166,8 +166,10 @@ pub async fn run_step( drain_queued(deps, &session, &record.session_id).await?; // First-step bookkeeping: mark working + emit turn-started + pre_turn hook. + // The reason is the live phase detail UIs render while the shimmer shows; + // it advances to "waiting for " right before the generation RPC. let _ = session - .set_status(&record.session_id, "working", None) + .set_status(&record.session_id, "working", Some("preparing context")) .await; if payload.step == 0 && record.turn_count == 0 { deps.events @@ -380,6 +382,13 @@ pub async fn run_step( // stop blocked behind the whole step and a step of tool execution leaking. drop(_guard); + // Phase detail: context is assembled, the provider round-trip starts now. + // This is the window users actually wait in (provider time-to-first-token). + let waiting_reason = format!("waiting for {}", record.options.model); + let _ = session + .set_status(&record.session_id, "working", Some(&waiting_reason)) + .await; + let router = deps.router().await; let outcome = router.chat(params, &sink).await?; diff --git a/session-manager/architecture/internals.md b/session-manager/architecture/internals.md index 9f7cd28aa..7ca9e2ec2 100644 --- a/session-manager/architecture/internals.md +++ b/session-manager/architecture/internals.md @@ -86,9 +86,10 @@ one is a regression even if all types still line up): 6. **Entry timestamps never change after creation** (they anchor history); event timestamps are the mutation time. `meta.updated_at` bumps on every mutation of the session. -7. **`set_status` is spec-strict:** same status ⇒ no write, no event — even if - the `reason` differs. `status_reason` is stored only with `error` and - cleared by any other status. +7. **`set_status` is spec-strict:** same status AND same stored reason ⇒ no + write, no event. A reason change alone re-emits (live phase detail within + one `working` stretch). `status_reason` is stored with `error` (failure + cause) and `working` (phase detail), cleared by `idle`/`done`. 8. **`set_meta` replaces `metadata` wholesale** when supplied (it is the tenancy hook; merging would leak stale keys). An all-`None` request is a silent no-op. diff --git a/session-manager/src/functions/set_status.rs b/session-manager/src/functions/set_status.rs index 530db25c2..22e72a52b 100644 --- a/session-manager/src/functions/set_status.rs +++ b/session-manager/src/functions/set_status.rs @@ -12,8 +12,9 @@ pub struct SetStatusRequest { pub session_id: String, /// Target status: `idle` / `working` / `done` / `error`. pub status: SessionStatus, - /// Short cause stored as `status_reason` — kept on `error`, - /// cleared on any other status. + /// Short cause/phase stored as `status_reason` — kept on `error` + /// (failure cause) and `working` (live phase detail), cleared on + /// `idle`/`done`. pub reason: Option, } diff --git a/session-manager/src/service.rs b/session-manager/src/service.rs index d099eaab4..2e73bc5e4 100644 --- a/session-manager/src/service.rs +++ b/session-manager/src/service.rs @@ -349,9 +349,18 @@ impl SessionService { let _guard = self.lock_session(&req.session_id).await; let mut meta = self.meta_or_not_found(&req.session_id).await?; - // Spec-strict no-op: same status fires no event, even if the - // reason differs. - if meta.status == req.status { + // Reason is retained while `working` (live phase detail, e.g. + // "waiting for ") and on `error` (failure cause); + // idle/done always clear it. + let new_reason = match req.status { + SessionStatus::Working | SessionStatus::Error => req.reason, + _ => None, + }; + + // Spec-strict no-op: same status AND same stored reason fires no + // event. A reason change alone re-emits so UIs can render live + // phase updates within one `working` stretch. + if meta.status == req.status && meta.status_reason == new_reason { return Ok(( SetStatusResponse { status: meta.status, @@ -363,11 +372,7 @@ impl SessionService { let previous_status = meta.status; meta.status = req.status; - meta.status_reason = if req.status == SessionStatus::Error { - req.reason - } else { - None - }; + meta.status_reason = new_reason; let now = self.clock.now_ms(); meta.updated_at = now; self.store.put_meta(&meta).await?; diff --git a/session-manager/tests/features/status.feature b/session-manager/tests/features/status.feature index ef876d99f..9e8f26843 100644 --- a/session-manager/tests/features/status.feature +++ b/session-manager/tests/features/status.feature @@ -4,9 +4,12 @@ Feature: session::set-status — the coarse lifecycle status Contract (session-manager.md § Session status / session::set-status): idle -> working -> done/error is driven by the harness; consumers render it directly (spinner, done badge, list filter). set_status is - a NO-OP (no event) when the status is unchanged. reason is stored as - status_reason — kept on "error", cleared on any other status — so a - standalone UI can render failures without asking the harness. + a NO-OP (no event) when both the status AND the stored reason are + unchanged; a reason change alone re-emits so UIs can render live + phase updates within one "working" stretch. reason is stored as + status_reason — kept on "error" (failure cause) and "working" (phase + detail), cleared on "idle"/"done" — so a standalone UI can render + progress and failures without asking the harness. Background: Given a bare session @@ -50,27 +53,48 @@ Feature: session::set-status — the coarse lifecycle status And the response field "previous_status" is "working" And function "ui::status" received 1 "session::status-changed" delivery - # Prevents: spec-strict regression — same status with a DIFFERENT - # reason is still a no-op; the stored reason must not silently change - # and the second call must not emit a duplicate status_changed. - Scenario: same status with a different reason is still a no-op + # Prevents: invisible in-turn phases — a reason change within one + # "working" stretch must update the stored reason and re-emit so a + # UI can walk "preparing context" -> "waiting for " live. + Scenario: a working-phase reason change updates and re-emits Given a binding "b1" on "session::status-changed" delivering to "ui::status" with config: """ {} """ Given I call "session::set-status" with: """ - { "session_id": "s_001", "status": "error", "reason": "rate limited" } + { "session_id": "s_001", "status": "working", "reason": "preparing context" } """ When I call "session::set-status" with: """ - { "session_id": "s_001", "status": "error", "reason": "DIFFERENT" } + { "session_id": "s_001", "status": "working", "reason": "waiting for claude" } """ And I call "session::get" with: """ { "session_id": "s_001" } """ - Then the response field "meta.status_reason" is "rate limited" + Then the response field "meta.status_reason" is "waiting for claude" + And function "ui::status" received 2 "session::status-changed" deliveries + And delivery 1 to "ui::status" has "status" = "working" + And delivery 1 to "ui::status" has "previous_status" = "working" + And delivery 1 to "ui::status" has "status_reason" = "waiting for claude" + + # Prevents: duplicate events on redundant stamps — same status AND + # same reason together stay a strict no-op. + Scenario: same status with the same reason is still a no-op + Given a binding "b1" on "session::status-changed" delivering to "ui::status" with config: + """ + {} + """ + Given I call "session::set-status" with: + """ + { "session_id": "s_001", "status": "working", "reason": "preparing context" } + """ + When I call "session::set-status" with: + """ + { "session_id": "s_001", "status": "working", "reason": "preparing context" } + """ + Then the call succeeds And function "ui::status" received 1 "session::status-changed" delivery # Prevents: failures without a cause — error status must carry the diff --git a/session-manager/tests/golden/schemas/session.set-status.json b/session-manager/tests/golden/schemas/session.set-status.json index 5611c4518..6c880825f 100644 --- a/session-manager/tests/golden/schemas/session.set-status.json +++ b/session-manager/tests/golden/schemas/session.set-status.json @@ -16,7 +16,7 @@ }, "properties": { "reason": { - "description": "Short cause stored as `status_reason` — kept on `error`, cleared on any other status.", + "description": "Short cause/phase stored as `status_reason` — kept on `error` (failure cause) and `working` (live phase detail), cleared on `idle`/`done`.", "type": [ "string", "null" diff --git a/tech-specs/2026-06-agentic/session-manager.md b/tech-specs/2026-06-agentic/session-manager.md index ed4aefec6..425561d18 100644 --- a/tech-specs/2026-06-agentic/session-manager.md +++ b/tech-specs/2026-06-agentic/session-manager.md @@ -29,7 +29,8 @@ Every session has a coarse lifecycle status that consumers can render directly ( badge, a list filter): - `idle` — created and waiting; no work has run yet. -- `working` — the agent is thinking/responding (a turn is running). +- `working` — the agent is thinking/responding (a turn is running); the optional `status_reason` + carries the live phase (e.g. `preparing context`, `waiting for `). - `done` — the agent finished the job (completed or cancelled) and the session is at rest. - `error` — the last turn failed; the optional `status_reason` carries a short cause. Distinct from `done` so a **standalone** UI can render failures without asking the harness. @@ -187,7 +188,7 @@ type StatusChangedEvent = { session_id: string; status: SessionStatus; previous_status: SessionStatus; - status_reason?: string; // short cause, set on "error" + status_reason?: string; // failure cause on "error", live phase on "working" timestamp: number; }; ``` @@ -242,7 +243,7 @@ type SessionMeta = { title: string; description: string; status: SessionStatus; // "idle" | "working" | "done" | "error" - status_reason?: string; // short cause, set on "error" + status_reason?: string; // failure cause on "error", live phase on "working" metadata?: Record; // app-defined; the tenancy hook (e.g. { owner: "u_1" }) forked_from?: string | null; created_at: number; @@ -339,8 +340,10 @@ type SetMetaResponse = { meta: SessionMeta }; ### `session::set-status` -Set the session status. Fires `session::status-changed`. No-op (no event) if the status is unchanged. -`reason` is stored as `status_reason` (typically set with `error`, cleared on any other status). +Set the session status. Fires `session::status-changed`. No-op (no event) if both the status and the +stored reason are unchanged; a reason change alone re-emits so UIs can render live phase updates +within one `working` stretch. `reason` is stored as `status_reason` — kept on `error` (failure +cause) and `working` (live phase detail), cleared on `idle`/`done`. - Invocation: **sync**