Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions console/web/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThinkingLevel>(
DEFAULT_THINKING_LEVEL,
)
Expand Down Expand Up @@ -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 = ''
Expand Down Expand Up @@ -1131,6 +1138,7 @@ export function ChatView({
})
}
setIsStreaming(false)
setTurnPhase(null)
assistantId = null
assistantBuffer = ''
break
Expand All @@ -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
}
Comment on lines +1165 to +1169

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant files and surrounding code paths.
git ls-files | rg '^(console/web/src/components/chat/ChatView\.tsx|.*real\.ts|.*turn_loop\.rs|.*send.*ts|.*status.*ts)$'

echo '--- ChatView.tsx ---'
wc -l console/web/src/components/chat/ChatView.tsx
sed -n '1120,1205p' console/web/src/components/chat/ChatView.tsx

echo '--- search turn-status handling ---'
rg -n "turn-status|setTurnPhase|phaseDetail|turnPhase" console/web/src/components/chat/ChatView.tsx console/web/src -g '!**/dist/**' -g '!**/build/**'

echo '--- real.ts candidates ---'
fd -a 'real.ts' console/web/src
for f in $(fd -a 'real.ts' console/web/src); do
  echo "--- $f ---"
  wc -l "$f"
  sed -n '1,260p' "$f" | rg -n "turn-status|send-resolved|onStarted|phase|harness::send|status_reason|queued|accepted|merged|started"
done

echo '--- turn_loop.rs candidates ---'
fd -a 'turn_loop.rs' .
for f in $(fd -a 'turn_loop.rs' .); do
  echo "--- $f ---"
  wc -l "$f"
  sed -n '1,260p' "$f" | rg -n "emit_started|started|queued|accepted|merged|turn-status|status_reason|phase"
done

Repository: iii-hq/workers

Length of output: 8073


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- console/web/src/lib/backend/translate.ts ---'
sed -n '1,140p' console/web/src/lib/backend/translate.ts

echo '--- console/web/src/lib/backend/real.ts (relevant sections) ---'
sed -n '180,260p' console/web/src/lib/backend/real.ts
echo '...'
sed -n '300,420p' console/web/src/lib/backend/real.ts

echo '--- harness/src/turn_loop.rs (relevant sections) ---'
sed -n '150,240p' harness/src/turn_loop.rs
echo '...'
sed -n '180,240p' harness/src/turn_loop.rs

echo '--- ChatView phase rendering ---'
sed -n '1290,1325p' console/web/src/components/chat/ChatView.tsx

Repository: iii-hq/workers

Length of output: 18958


Guard turnPhase against out-of-order updates. turn-status events come from two async paths: started from the live turn-start subscription, and accepted/merged/queued from the separate harness::send response. If the response lands after the turn has already started, it can overwrite started and show a stale pre-content phase. Keep started monotonic here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@console/web/src/components/chat/ChatView.tsx` around lines 1165 - 1169,
Update the `turn-status` case in the ChatView event handler to prevent stale
`accepted`, `merged`, or `queued` updates from overwriting an already-recorded
`started` phase. Preserve `turnPhase` as `started` once received, while
retaining the existing behavior of mapping `queued` to `null` when no start has
occurred.

case 'stop-reason': {
let noticeContent = formatStopReason(event.reason, event.message)
if (event.partialResultAvailable) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1214,6 +1229,7 @@ export function ChatView({
onPatchMessage(conversationId, assistantId, { streaming: false })
}
setIsStreaming(false)
setTurnPhase(null)
abortRef.current = null
}
},
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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}
Expand Down
8 changes: 7 additions & 1 deletion console/web/src/components/ui/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { cn } from '@/lib/utils'
interface SelectOption<T extends string> {
value: T
label: string
/** Optional hover tooltip on the option row. */
title?: string
}

interface SelectGroup<T extends string> {
Expand Down Expand Up @@ -189,6 +191,7 @@ export function Select<T extends string>({
key={opt.value}
value={opt.value}
label={opt.label}
title={opt.title}
/>
))}
</SelectPrimitive.Group>
Expand All @@ -198,6 +201,7 @@ export function Select<T extends string>({
key={opt.value}
value={opt.value}
label={opt.label}
title={opt.title}
/>
))}
</SelectPrimitive.Viewport>
Expand All @@ -223,12 +227,14 @@ export function Select<T extends string>({
interface SelectItemProps {
value: string
label: string
title?: string
}

function SelectItem({ value, label }: SelectItemProps) {
function SelectItem({ value, label, title }: SelectItemProps) {
return (
<SelectPrimitive.Item
value={value}
title={title}
className={cn(
'relative flex items-center pl-7 pr-3 py-1.5 cursor-pointer outline-none select-none',
'data-[highlighted]:bg-rule data-[highlighted]:text-ink',
Expand Down
17 changes: 10 additions & 7 deletions console/web/src/lib/backend/real.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ async function* realStream(

const stopTurnEvents = startTurnEventsSubscription(client, sessionId, {
onCompleted: (event) => push({ kind: 'turn-completed', event }),
onStarted: (event) => push({ kind: 'turn-started', event }),
})
const stopApprovalEvents = opts?.approvalEventsExternallyManaged
? () => {}
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions console/web/src/lib/backend/translate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
PendingResolvedEvent,
TurnCompletedEvent,
} from '@/types/iii-agent-event'
import type { HarnessSendResponse } from './harness-send'
import {
isTerminalSource,
type TurnSourceEvent,
Expand Down Expand Up @@ -239,3 +240,57 @@ describe('translateTurnSource — turn-completed', () => {
).toBe(false)
})
})

describe('translateTurnSource — pre-content turn status', () => {
function sendResolved(
over: Partial<HarnessSendResponse> = {},
): 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)
})
})
23 changes: 23 additions & 0 deletions console/web/src/lib/backend/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -21,14 +23,18 @@ 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. */
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 {
Expand Down Expand Up @@ -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',
},
]
}
}
}

Expand Down
11 changes: 11 additions & 0 deletions console/web/src/lib/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions console/web/src/stories/playground/EventLog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}

Expand Down
11 changes: 10 additions & 1 deletion harness/src/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <model>" 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
Expand Down Expand Up @@ -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?;

Expand Down
7 changes: 4 additions & 3 deletions session-manager/architecture/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions session-manager/src/functions/set_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

Expand Down
Loading
Loading