From d146b201b0890fdffc3415fdfe305147442c47c2 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Thu, 11 Jun 2026 22:23:16 +0800 Subject: [PATCH 1/4] web: group transcript by turn and render turn failures in place Surface core's Turn state machine in the browser projection instead of flattening it away: - mqueue: listen to turn_started SSE (core already emitted it; the browser dropped it), keep turnsBySessionId, mark turns failed on turn_failed, and merge turns from runtime snapshots and send responses. - mqueue: build TurnGroup[] from the flat timeline. Attribution: tool calls via turn_id, streaming transients via the stream_ synthetic id, user messages via trigger_ref, everything else via the input_through_session_seq watermark. Unattributable items fall into envelope-less groups so old data always renders. - mqueue: extract the projection write surface into projection.ts; it owns every mutation that keeps messages, the flat timeline, and the turn-grouped timeline consistent. - hooks: replace useSessionTimeline with useSessionTurnTimeline. - web: Transcript renders turn groups; a failed turn shows TURN FAILED with its error_text in place in the transcript, and the composer notice suppresses errors a failed turn already carries. Verified live: history rebuild, streaming send, SSE turn_failed, HTTP-rebuilt failed turns, and recovery after provider restore. --- apps/client/soma/web/src/App.tsx | 22 ++- .../soma/web/src/components/Transcript.tsx | 36 ++++- packages/hooks/src/index.tsx | 8 +- packages/mqueue/src/events.ts | 140 ++++++++++++++++++ packages/mqueue/src/index.ts | 93 ++++-------- packages/mqueue/src/projection.ts | 129 ++++++++++++++++ packages/mqueue/src/stream.ts | 3 + packages/mqueue/src/types.ts | 20 ++- 8 files changed, 375 insertions(+), 76 deletions(-) create mode 100644 packages/mqueue/src/projection.ts diff --git a/apps/client/soma/web/src/App.tsx b/apps/client/soma/web/src/App.tsx index c6827ee..0adffa5 100644 --- a/apps/client/soma/web/src/App.tsx +++ b/apps/client/soma/web/src/App.tsx @@ -7,7 +7,7 @@ import { useSessionActions, useSessionError, useSessionPending, - useSessionTimeline, + useSessionTurnTimeline, useSessions, } from "@mini-stim/hooks"; @@ -17,7 +17,7 @@ import { SessionRail } from "./components/SessionRail"; export function App() { const sessions = useSessions(); const selectedSessionId = useSelectedSessionId(); - const timeline = useSessionTimeline(); + const timeline = useSessionTurnTimeline(); const pending = useSessionPending(); const sessionError = useSessionError(); const connection = useMessageConnection(); @@ -27,7 +27,23 @@ export function App() { const busy = pending > 0; const debouncedBusy = useDebouncedValue(busy, { debounceMs: 150 }); - const visibleError = error ?? sessionError?.message ?? null; + // Turn failures render in place inside the transcript; the composer + // notice keeps only errors that no failed turn already carries. + const inPlaceErrors = useMemo( + () => + new Set( + timeline + .filter((group) => group.turn?.status === "failed") + .map((group) => group.turn?.error_text) + .filter((text): text is string => Boolean(text)), + ), + [timeline], + ); + const sessionErrorMessage = + sessionError && !inPlaceErrors.has(sessionError.message) + ? sessionError.message + : null; + const visibleError = error ?? sessionErrorMessage; const debouncedConnection = useDebouncedValue(connection, { debounceMs: 150 }); const selectedTitle = useMemo(() => { const selected = sessions.find((session) => session.id === selectedSessionId); diff --git a/apps/client/soma/web/src/components/Transcript.tsx b/apps/client/soma/web/src/components/Transcript.tsx index 2f7194d..53cfae4 100644 --- a/apps/client/soma/web/src/components/Transcript.tsx +++ b/apps/client/soma/web/src/components/Transcript.tsx @@ -1,19 +1,22 @@ import { Notice, Pane, ScrollArea, Stack, Text } from "@mini-stim/components"; -import type { TimelineItem } from "@mini-stim/hooks"; +import type { TurnGroup } from "@mini-stim/hooks"; import { TimelineItemView } from "./TimelineItemView"; export function Transcript(props: { - timeline: TimelineItem[]; + timeline: TurnGroup[]; }) { + const empty = !props.timeline.some( + (group) => group.items.length || group.turn, + ); return ( - {props.timeline.map((item) => ( - + {props.timeline.map((group) => ( + ))} - {!props.timeline.length ? ( + {empty ? ( Start a session to see the transcript build here. @@ -23,3 +26,26 @@ export function Transcript(props: { ); } + +function TurnGroupView(props: { group: TurnGroup }) { + const { group } = props; + const turn = group.turn; + return ( + + {group.items.map((item) => ( + + ))} + {turn?.status === "failed" ? ( + + + TURN FAILED + {turn.error_text ?? "The turn failed without an error message."} + + + ) : null} + {turn?.status === "running" && !group.items.length ? ( + Working… + ) : null} + + ); +} diff --git a/packages/hooks/src/index.tsx b/packages/hooks/src/index.tsx index 2230f20..43a211c 100644 --- a/packages/hooks/src/index.tsx +++ b/packages/hooks/src/index.tsx @@ -21,6 +21,7 @@ import { type SessionMessage, type SessionProjection, type TimelineItem, + type TurnGroup, } from "@mini-stim/mqueue"; export type { @@ -33,6 +34,9 @@ export type { SessionMessage, SessionProjection, TimelineItem, + Turn, + TurnGroup, + TurnStatus, } from "@mini-stim/mqueue"; interface SantiMqueueProviderProps { @@ -114,7 +118,7 @@ export function useSessionMessages(sessionId?: string | null): SessionMessage[] return projection.messagesBySessionId[resolvedSessionId] ?? []; } -export function useSessionTimeline(sessionId?: string | null): TimelineItem[] { +export function useSessionTurnTimeline(sessionId?: string | null): TurnGroup[] { const mqueue = useSantiMqueue(); const selectedSessionId = useSelectedSessionId(); const store = useMemo(() => createMessageStore(mqueue), [mqueue]); @@ -127,7 +131,7 @@ export function useSessionTimeline(sessionId?: string | null): TimelineItem[] { if (!resolvedSessionId) { return []; } - return projection.timelineBySessionId[resolvedSessionId] ?? []; + return projection.turnTimelineBySessionId[resolvedSessionId] ?? []; } export function useSessionPending(): number { diff --git a/packages/mqueue/src/events.ts b/packages/mqueue/src/events.ts index 0c51b30..8af0ec5 100644 --- a/packages/mqueue/src/events.ts +++ b/packages/mqueue/src/events.ts @@ -4,6 +4,7 @@ import type { SessionMessage, ToolCall, ToolResult, + Turn, } from "@mini-stim/contracts"; import type { @@ -19,6 +20,7 @@ import type { SessionProjection, StreamEvent, TimelineItem, + TurnGroup, } from "./types"; const DEFAULT_ACTOR_ID = "account_local"; @@ -197,6 +199,18 @@ export function cloneMessageProjection(value: MessageProjection): MessageProject [...items], ]), ), + turnTimelineBySessionId: Object.fromEntries( + Object.entries(value.turnTimelineBySessionId).map(([sessionId, groups]) => [ + sessionId, + groups.map((group) => ({ ...group, items: [...group.items] })), + ]), + ), + turnsBySessionId: Object.fromEntries( + Object.entries(value.turnsBySessionId).map(([sessionId, turns]) => [ + sessionId, + [...turns], + ]), + ), toolCallsBySessionId: Object.fromEntries( Object.entries(value.toolCallsBySessionId).map(([sessionId, calls]) => [ sessionId, @@ -212,6 +226,132 @@ export function cloneMessageProjection(value: MessageProjection): MessageProject }; } +export function dedupeTurns(turns: Turn[]): Turn[] { + const byId = new Map(); + for (const turn of turns) { + const existing = byId.get(turn.id); + if (!existing || existing.updated_at.localeCompare(turn.updated_at) <= 0) { + byId.set(turn.id, turn); + } + } + return [...byId.values()].sort( + (left, right) => + left.input_through_session_seq - right.input_through_session_seq || + left.created_at.localeCompare(right.created_at), + ); +} + +const STREAM_MESSAGE_PREFIX = "stream_"; + +/** + * Group a flat, time-sorted timeline into turn envelopes. + * + * Attribution rules, strongest first: + * - tool_call / paired tool_result: `turn_id` on the call. + * - transient streaming message: its id is `stream_` (core emits + * MessageDelta with that synthetic id). + * - user message: the turn whose `trigger_ref` is the message id. + * - anything else: the turn with the largest `input_through_session_seq` + * that is <= the message's `session_seq` (each turn's appended messages + * sit after its input watermark and before the next turn's). + * + * Items that resolve to no turn (turns not loaded yet, old data) fall into + * envelope-less groups, one per consecutive run, so render order is + * preserved and nothing ever fails to render. Turns with no attributed + * items (just-started turns) still produce an empty group so the UI can + * show a running envelope immediately. + */ +export function turnGroups( + sessionId: string, + items: TimelineItem[], + turns: Turn[], +): TurnGroup[] { + const sortedTurns = dedupeTurns(turns); + const turnsById = new Map(sortedTurns.map((turn) => [turn.id, turn])); + const groupsByTurnId = new Map(); + const groups: TurnGroup[] = []; + let fallback: TurnGroup | null = null; + + const ensureTurnGroup = (turn: Turn): TurnGroup => { + let group = groupsByTurnId.get(turn.id); + if (!group) { + group = { + id: turn.id, + sessionId, + createdAt: turn.created_at, + turn, + items: [], + }; + groupsByTurnId.set(turn.id, group); + groups.push(group); + } + return group; + }; + + for (const item of items) { + const turn = attributeItem(item, sortedTurns, turnsById); + if (turn) { + fallback = null; + const group = ensureTurnGroup(turn); + group.items.push(item); + if (item.createdAt.localeCompare(group.createdAt) < 0) { + group.createdAt = item.createdAt; + } + } else { + if (!fallback) { + fallback = { + id: `ungrouped_${item.id}`, + sessionId, + createdAt: item.createdAt, + items: [], + }; + groups.push(fallback); + } + fallback.items.push(item); + } + } + + for (const turn of sortedTurns) { + ensureTurnGroup(turn); + } + + return groups.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); +} + +function attributeItem( + item: TimelineItem, + sortedTurns: Turn[], + turnsById: Map, +): Turn | undefined { + if (item.kind === "tool_call") { + return turnsById.get(item.toolCall.turn_id); + } + if (item.kind === "tool_result") { + return item.toolCall ? turnsById.get(item.toolCall.turn_id) : undefined; + } + const messageId = item.message.message.id; + if (messageId.startsWith(STREAM_MESSAGE_PREFIX)) { + const turn = turnsById.get(messageId.slice(STREAM_MESSAGE_PREFIX.length)); + if (turn) { + return turn; + } + } + const byTrigger = sortedTurns.find((turn) => turn.trigger_ref === messageId); + if (byTrigger) { + return byTrigger; + } + const seq = item.message.relation.session_seq; + let candidate: Turn | undefined; + for (const turn of sortedTurns) { + if (turn.input_through_session_seq <= seq) { + candidate = turn; + } else { + break; + } + } + return candidate; +} + export function dedupeToolCalls(calls: ToolCall[]): ToolCall[] { return [...new Map(calls.map((call) => [call.id, call])).values()].sort( (left, right) => left.created_at.localeCompare(right.created_at), diff --git a/packages/mqueue/src/index.ts b/packages/mqueue/src/index.ts index cbb5784..b166ecc 100644 --- a/packages/mqueue/src/index.ts +++ b/packages/mqueue/src/index.ts @@ -15,22 +15,18 @@ import { } from "@mini-stim/contracts"; import { - appendText, cloneMessageProjection, cloneProjection, dedupeMessages, - dedupeToolCalls, - dedupeToolResults, dispatchMessage, dispatchSession, expectStatus, messageEvent, normalizeError, sessionEvent, - timelineItems, - transientMessage, validateSessionPayload, } from "./events"; +import { createProjectionWriter } from "./projection"; import { openSessionStream } from "./stream"; import type { MessageConnectionState, @@ -51,6 +47,7 @@ import type { ToolCall, ToolResult, TimelineItem, + Turn, } from "./types"; export type { @@ -77,6 +74,9 @@ export type { TimelineItem, ToolCall, ToolResult, + Turn, + TurnGroup, + TurnStatus, } from "./types"; declare global { @@ -113,12 +113,23 @@ function createMqueueCore(target: Window): SantiMqueue { const messageState: MessageProjection = { messagesBySessionId: {}, timelineBySessionId: {}, + turnTimelineBySessionId: {}, + turnsBySessionId: {}, toolCallsBySessionId: {}, toolResultsBySessionId: {}, connectionBySessionId: {}, }; let activeSessionId: string | null = null; let eventSource: EventSource | null = null; + const { + applyDelta, + markTurnFailed, + removeTransient, + setMessageProjection, + upsertMessages, + upsertTools, + upsertTurns, + } = createProjectionWriter(state, messageState); const session: SessionMqueue = { pub, sub: subSession, snapshot }; const message: MessageMqueue = { sub: subMessage, snapshot: messageSnapshot }; @@ -299,6 +310,7 @@ function createMqueueCore(target: Window): SantiMqueue { 200, ) as SessionRuntimeSnapshot; state.runtimeBySessionId[runtimePayload.sessionId] = runtime; + upsertTurns(runtimePayload.sessionId, runtime.turns); upsertTools(runtimePayload.sessionId, runtime.tool_calls, runtime.tool_results); emitMessageProjection(runtimePayload.sessionId); dispatchSession(target, sessionEvent(action, "committed", runtime, "http")); @@ -344,6 +356,7 @@ function createMqueueCore(target: Window): SantiMqueue { response.user_message, response.assistant_message, ]); + upsertTurns(response.session.id, [response.turn]); upsertTools(response.session.id, response.tool_calls, response.tool_results); state.messages = state.messagesBySessionId[response.session.id]; emitMessageProjection(response.session.id); @@ -401,16 +414,21 @@ function createMqueueCore(target: Window): SantiMqueue { dispatchMessage(target, messageEvent("tool_result", sessionId, payload, "sse")); emitMessageProjection(sessionId); }, + turnStarted: (payload) => { + upsertTurns(sessionId, [payload.turn]); + dispatchMessage(target, messageEvent("turn_started", sessionId, payload, "sse")); + emitMessageProjection(sessionId); + }, turnFailed: (payload) => { - const failure = { code: "turn_failed", message: payload.error }; - state.error = failure; + markTurnFailed(sessionId, payload.turn_id, payload.error); dispatchMessage( target, messageEvent("failed", sessionId, payload, "sse", { - error: failure, + error: { code: "turn_failed", message: payload.error }, }), ); emitProjection(); + emitMessageProjection(sessionId); }, }); } @@ -434,65 +452,10 @@ function createMqueueCore(target: Window): SantiMqueue { emitMessageProjection(sessionId); } - function applyDelta(sessionId: string, payload: MessageDeltaPayload) { - const existing = state.messagesBySessionId[sessionId] ?? []; - const index = existing.findIndex((message) => message.message.id === payload.message_id); - if (index === -1) { - state.messagesBySessionId[sessionId] = [...existing, transientMessage(sessionId, payload)]; - } else { - const current = existing[index]; - state.messagesBySessionId[sessionId] = [ - ...existing.slice(0, index), - appendText(current, payload.text), - ...existing.slice(index + 1), - ]; - } - if (state.selectedSessionId === sessionId) { - state.messages = state.messagesBySessionId[sessionId]; - } - setMessageProjection(sessionId); - } - function removeTransient(sessionId: string, turnId: string) { - const transientId = `stream_${turnId}`; - state.messagesBySessionId[sessionId] = (state.messagesBySessionId[sessionId] ?? []).filter( - (message) => message.message.id !== transientId, - ); - } - function upsertMessages(sessionId: string, messages: SessionMessage[]) { - state.messagesBySessionId[sessionId] = dedupeMessages([ - ...(state.messagesBySessionId[sessionId] ?? []), - ...messages, - ]); - if (state.selectedSessionId === sessionId) { - state.messages = state.messagesBySessionId[sessionId]; - } - setMessageProjection(sessionId); - } - function upsertTools(sessionId: string, calls: ToolCall[], results: ToolResult[]) { - messageState.toolCallsBySessionId[sessionId] = dedupeToolCalls([ - ...(messageState.toolCallsBySessionId[sessionId] ?? []), - ...calls, - ]); - messageState.toolResultsBySessionId[sessionId] = dedupeToolResults([ - ...(messageState.toolResultsBySessionId[sessionId] ?? []), - ...results, - ]); - setMessageProjection(sessionId); - } - function setMessageProjection(sessionId: string) { - const messages = state.messagesBySessionId[sessionId] ?? []; - const calls = messageState.toolCallsBySessionId[sessionId] ?? []; - const results = messageState.toolResultsBySessionId[sessionId] ?? []; - messageState.messagesBySessionId[sessionId] = messages; - messageState.timelineBySessionId[sessionId] = timelineItems( - sessionId, - messages, - calls, - results, - ); - } + + } diff --git a/packages/mqueue/src/projection.ts b/packages/mqueue/src/projection.ts new file mode 100644 index 0000000..b8d0e22 --- /dev/null +++ b/packages/mqueue/src/projection.ts @@ -0,0 +1,129 @@ +import { + appendText, + dedupeMessages, + dedupeToolCalls, + dedupeToolResults, + dedupeTurns, + timelineItems, + transientMessage, + turnGroups, +} from "./events"; +import type { + MessageDeltaPayload, + MessageProjection, + SessionMessage, + SessionProjection, + ToolCall, + ToolResult, + Turn, +} from "./types"; + +/** + * Mutation surface over the two projection states. Owns every write that + * must keep the per-session message list, flat timeline, and turn-grouped + * timeline consistent with each other. + */ +export function createProjectionWriter( + state: SessionProjection, + messageState: MessageProjection, +) { + return { + applyDelta, + markTurnFailed, + removeTransient, + setMessageProjection, + upsertMessages, + upsertTools, + upsertTurns, + }; + + function applyDelta(sessionId: string, payload: MessageDeltaPayload) { + const existing = state.messagesBySessionId[sessionId] ?? []; + const index = existing.findIndex((message) => message.message.id === payload.message_id); + if (index === -1) { + state.messagesBySessionId[sessionId] = [...existing, transientMessage(sessionId, payload)]; + } else { + const current = existing[index]; + state.messagesBySessionId[sessionId] = [ + ...existing.slice(0, index), + appendText(current, payload.text), + ...existing.slice(index + 1), + ]; + } + if (state.selectedSessionId === sessionId) { + state.messages = state.messagesBySessionId[sessionId]; + } + setMessageProjection(sessionId); + } + + function removeTransient(sessionId: string, turnId: string) { + const transientId = `stream_${turnId}`; + state.messagesBySessionId[sessionId] = (state.messagesBySessionId[sessionId] ?? []).filter( + (message) => message.message.id !== transientId, + ); + } + + function upsertMessages(sessionId: string, messages: SessionMessage[]) { + state.messagesBySessionId[sessionId] = dedupeMessages([ + ...(state.messagesBySessionId[sessionId] ?? []), + ...messages, + ]); + if (state.selectedSessionId === sessionId) { + state.messages = state.messagesBySessionId[sessionId]; + } + setMessageProjection(sessionId); + } + + function upsertTurns(sessionId: string, turns: Turn[]) { + messageState.turnsBySessionId[sessionId] = dedupeTurns([ + ...(messageState.turnsBySessionId[sessionId] ?? []), + ...turns, + ]); + setMessageProjection(sessionId); + } + + function markTurnFailed(sessionId: string, turnId: string, error: string) { + const turns = messageState.turnsBySessionId[sessionId] ?? []; + const index = turns.findIndex((turn) => turn.id === turnId); + if (index === -1) { + return; + } + const failed: Turn = { + ...turns[index], + status: "failed", + error_text: error, + }; + messageState.turnsBySessionId[sessionId] = [ + ...turns.slice(0, index), + failed, + ...turns.slice(index + 1), + ]; + setMessageProjection(sessionId); + } + + function upsertTools(sessionId: string, calls: ToolCall[], results: ToolResult[]) { + messageState.toolCallsBySessionId[sessionId] = dedupeToolCalls([ + ...(messageState.toolCallsBySessionId[sessionId] ?? []), + ...calls, + ]); + messageState.toolResultsBySessionId[sessionId] = dedupeToolResults([ + ...(messageState.toolResultsBySessionId[sessionId] ?? []), + ...results, + ]); + setMessageProjection(sessionId); + } + + function setMessageProjection(sessionId: string) { + const messages = state.messagesBySessionId[sessionId] ?? []; + const calls = messageState.toolCallsBySessionId[sessionId] ?? []; + const results = messageState.toolResultsBySessionId[sessionId] ?? []; + messageState.messagesBySessionId[sessionId] = messages; + const timeline = timelineItems(sessionId, messages, calls, results); + messageState.timelineBySessionId[sessionId] = timeline; + messageState.turnTimelineBySessionId[sessionId] = turnGroups( + sessionId, + timeline, + messageState.turnsBySessionId[sessionId] ?? [], + ); + } +} diff --git a/packages/mqueue/src/stream.ts b/packages/mqueue/src/stream.ts index 6ea2cc2..352a9b4 100644 --- a/packages/mqueue/src/stream.ts +++ b/packages/mqueue/src/stream.ts @@ -4,6 +4,7 @@ import type { SessionMessage, ToolCall, ToolResult, + Turn, StreamPayload, } from "./types"; @@ -19,6 +20,7 @@ export interface SessionStreamHandlers { }): void; toolCall(payload: { type: "tool_call_created"; tool_call: ToolCall }): void; toolResult(payload: { type: "tool_result_created"; tool_result: ToolResult }): void; + turnStarted(payload: { type: "turn_started"; turn: Turn }): void; turnFailed(payload: { type: "turn_failed"; turn_id: string; error: string }): void; } @@ -34,6 +36,7 @@ export function openSessionStream( listen(source, "message_completed", "message_completed", handlers.messageCompleted); listen(source, "tool_call_created", "tool_call_created", handlers.toolCall); listen(source, "tool_result_created", "tool_result_created", handlers.toolResult); + listen(source, "turn_started", "turn_started", handlers.turnStarted); listen(source, "turn_failed", "turn_failed", handlers.turnFailed); return source; } diff --git a/packages/mqueue/src/types.ts b/packages/mqueue/src/types.ts index fe6ceb0..9353d6d 100644 --- a/packages/mqueue/src/types.ts +++ b/packages/mqueue/src/types.ts @@ -5,6 +5,8 @@ import type { SessionRuntimeSnapshot, ToolCall, ToolResult, + Turn, + TurnStatus, UpdateSessionRequest, } from "@mini-stim/contracts"; @@ -14,6 +16,8 @@ export type { SessionMessage, ToolCall, ToolResult, + Turn, + TurnStatus, } from "@mini-stim/contracts"; export type SessionAction = @@ -70,6 +74,7 @@ export type MessagePhase = | "completed" | "tool_call" | "tool_result" + | "turn_started" | "failed" | "projection"; @@ -78,11 +83,24 @@ export type MessageConnectionState = "closed" | "connecting" | "open" | "error"; export interface MessageProjection { messagesBySessionId: Record; timelineBySessionId: Record; + turnTimelineBySessionId: Record; + turnsBySessionId: Record; toolCallsBySessionId: Record; toolResultsBySessionId: Record; connectionBySessionId: Record; } +export interface TurnGroup { + /** Turn id, or `ungrouped_` for the fallback group. */ + id: string; + sessionId: string; + /** Sort key: the earliest createdAt among the turn and its items. */ + createdAt: string; + /** Absent for the fallback group holding items with no resolvable turn. */ + turn?: Turn; + items: TimelineItem[]; +} + export type TimelineItem = | { kind: "message"; @@ -196,7 +214,7 @@ export type StreamPayload = | { type: "message_completed"; turn_id: string; message: SessionMessage } | { type: "tool_call_created"; tool_call: ToolCall } | { type: "tool_result_created"; tool_result: ToolResult } - | { type: "turn_started"; turn: unknown } + | { type: "turn_started"; turn: Turn } | { type: "turn_failed"; turn_id: string; error: string }; export interface MessageDeltaPayload { From adfa8503886d86fe810b6dfbf8e65bb9d10d4b14 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Thu, 11 Jun 2026 22:28:52 +0800 Subject: [PATCH 2/4] web: surface streaming state in transcript and header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A pending (streaming) message shows 'generating…' in place of its timestamp instead of rendering identically to a fixed message. - The header busy chip derives its label from the running turn's actual phase — 'running tool' while a tool call awaits its result, 'generating' while a pending message streams, 'thinking' otherwise, with 'sending' as the no-turn fallback. The chip system stays quiet-by-default. Verified live: 'generating' chip during a streaming reply, normal completion afterwards. --- apps/client/soma/web/src/App.tsx | 20 +++++++++++++++++++ .../soma/web/src/components/ChatHeader.tsx | 3 ++- .../soma/web/src/components/ChatShell.tsx | 2 ++ .../web/src/components/TimelineItemView.tsx | 7 ++++++- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/client/soma/web/src/App.tsx b/apps/client/soma/web/src/App.tsx index 0adffa5..85e4940 100644 --- a/apps/client/soma/web/src/App.tsx +++ b/apps/client/soma/web/src/App.tsx @@ -27,6 +27,25 @@ export function App() { const busy = pending > 0; const debouncedBusy = useDebouncedValue(busy, { debounceMs: 150 }); + // While a turn is running, the header chip names the phase the turn is + // actually in instead of a generic "sending". + const activity = useMemo(() => { + const running = timeline.find((group) => group.turn?.status === "running"); + if (!running) { + return "sending"; + } + if (running.items.some((item) => item.kind === "tool_call" && !item.toolResult)) { + return "running tool"; + } + if ( + running.items.some( + (item) => item.kind === "message" && item.message.message.state === "pending", + ) + ) { + return "generating"; + } + return "thinking"; + }, [timeline]); // Turn failures render in place inside the transcript; the composer // notice keeps only errors that no failed turn already carries. const inPlaceErrors = useMemo( @@ -116,6 +135,7 @@ export function App() { void; @@ -86,7 +87,7 @@ export function ChatHeader(props: { {props.busy ? ( - sending + {props.activity} ) : null} {props.connection === "error" ? ( reconnecting diff --git a/apps/client/soma/web/src/components/ChatShell.tsx b/apps/client/soma/web/src/components/ChatShell.tsx index c2f5d07..39e50ca 100644 --- a/apps/client/soma/web/src/components/ChatShell.tsx +++ b/apps/client/soma/web/src/components/ChatShell.tsx @@ -5,6 +5,7 @@ import { Composer } from "./Composer"; import { Transcript } from "./Transcript"; export function ChatShell(props: { + activity: string; busy: boolean; connection: string; error: string | null; @@ -22,6 +23,7 @@ export function ChatShell(props: { {roleLabel(role)} - + {pending ? ( + generating… + ) : ( + + )} {item.message.content_text} From c6dffe6cacc423afa22f1aa6bfc4d39e061e924a Mon Sep 17 00:00:00 2001 From: PerishCode Date: Thu, 11 Jun 2026 22:37:57 +0800 Subject: [PATCH 3/4] web: add agent inspect panel over the session runtime snapshot The runtime snapshot (selectAndGet already fetches it) was stored in the projection but nothing read it. Sign for it: - mqueue/hooks: re-export the runtime domain types (SoulSession, Compact, SessionEffect, SessionRuntimeSnapshot) and add useSessionRuntime() plus a refreshRuntime action. - web: an Inspect toggle in the chat header swaps the transcript for a read-only panel showing the soul session's memory (with its last-seen seq watermark), compaction summaries with the seq ranges they replace, and hook effect statuses. Switching sessions returns to the transcript; opening the panel refreshes the snapshot. First visible landing of the 'transparent, inspectable local agent' direction. Verified live: panel renders memory/compacts/effects empty states with real seq watermark, toggle restores the transcript. --- apps/client/soma/web/src/App.tsx | 22 ++++- .../soma/web/src/components/ChatHeader.tsx | 13 ++- .../soma/web/src/components/ChatShell.tsx | 15 ++- .../soma/web/src/components/InspectPanel.tsx | 93 +++++++++++++++++++ packages/hooks/src/index.tsx | 19 ++++ packages/mqueue/src/index.ts | 4 + packages/mqueue/src/types.ts | 4 + 7 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 apps/client/soma/web/src/components/InspectPanel.tsx diff --git a/apps/client/soma/web/src/App.tsx b/apps/client/soma/web/src/App.tsx index 85e4940..574a9ab 100644 --- a/apps/client/soma/web/src/App.tsx +++ b/apps/client/soma/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AppRoot, Grid, GridItem } from "@mini-stim/components"; import { useDebouncedValue, @@ -7,6 +7,7 @@ import { useSessionActions, useSessionError, useSessionPending, + useSessionRuntime, useSessionTurnTimeline, useSessions, } from "@mini-stim/hooks"; @@ -22,8 +23,24 @@ export function App() { const sessionError = useSessionError(); const connection = useMessageConnection(); const actions = useSessionActions(); + const runtime = useSessionRuntime(); const [draft, setDraft] = useState(""); const [error, setError] = useState(null); + const [inspecting, setInspecting] = useState(false); + + // The inspect panel is a view over the selected session; switching + // sessions returns to the transcript. Opening it refreshes the snapshot + // so memory/compacts/effects reflect the turns since selection. + useEffect(() => { + setInspecting(false); + }, [selectedSessionId]); + + function toggleInspect() { + if (!inspecting && selectedSessionId) { + actions.refreshRuntime(selectedSessionId); + } + setInspecting((current) => !current); + } const busy = pending > 0; const debouncedBusy = useDebouncedValue(busy, { debounceMs: 150 }); @@ -139,9 +156,12 @@ export function App() { busy={debouncedBusy} connection={debouncedConnection} error={visibleError} + inspecting={inspecting} onDraftChange={setDraft} onSend={send} onTitleCommit={updateTitle} + onToggleInspect={toggleInspect} + runtime={runtime} selectedSessionId={selectedSessionId} title={selectedTitle} titleValue={selectedSession?.title ?? null} diff --git a/apps/client/soma/web/src/components/ChatHeader.tsx b/apps/client/soma/web/src/components/ChatHeader.tsx index afa0996..567e474 100644 --- a/apps/client/soma/web/src/components/ChatHeader.tsx +++ b/apps/client/soma/web/src/components/ChatHeader.tsx @@ -14,7 +14,9 @@ export function ChatHeader(props: { activity: string; busy: boolean; connection: string; + inspecting: boolean; onTitleCommit: (title: string | null) => void; + onToggleInspect: () => void; selectedSessionId: string | null; title: string; titleValue: string | null; @@ -85,13 +87,22 @@ export function ChatHeader(props: { )} - + {props.busy ? ( {props.activity} ) : null} {props.connection === "error" ? ( reconnecting ) : null} + diff --git a/apps/client/soma/web/src/components/ChatShell.tsx b/apps/client/soma/web/src/components/ChatShell.tsx index 39e50ca..61f40fe 100644 --- a/apps/client/soma/web/src/components/ChatShell.tsx +++ b/apps/client/soma/web/src/components/ChatShell.tsx @@ -1,7 +1,9 @@ import { Pane, SectionStackLayout } from "@mini-stim/components"; +import type { SessionRuntimeSnapshot } from "@mini-stim/hooks"; import { ChatHeader } from "./ChatHeader"; import { Composer } from "./Composer"; +import { InspectPanel } from "./InspectPanel"; import { Transcript } from "./Transcript"; export function ChatShell(props: { @@ -9,9 +11,12 @@ export function ChatShell(props: { busy: boolean; connection: string; error: string | null; + inspecting: boolean; onDraftChange: (value: string) => void; onSend: () => void; onTitleCommit: (title: string | null) => void; + onToggleInspect: () => void; + runtime: SessionRuntimeSnapshot | null; selectedSessionId: string | null; title: string; titleValue: string | null; @@ -26,13 +31,21 @@ export function ChatShell(props: { activity={props.activity} busy={props.busy} connection={props.connection} + inspecting={props.inspecting} onTitleCommit={props.onTitleCommit} + onToggleInspect={props.onToggleInspect} selectedSessionId={props.selectedSessionId} title={props.title} titleValue={props.titleValue} /> )} - middle={} + middle={ + props.inspecting ? ( + + ) : ( + + ) + } bottom={( + No runtime snapshot loaded for this session yet. + + ); + } + + const memory = runtime.soul_session?.session_memory.trim() ?? ""; + + return ( + + + + + + SESSION MEMORY + {runtime.soul_session ? ( + + seen through seq {runtime.soul_session.last_seen_session_seq} + + ) : null} + + {memory ? ( + {memory} + ) : ( + + The agent has not written any session memory yet. + + )} + + + + COMPACTS + {runtime.compacts.length ? ( + runtime.compacts.map((compact) => ( + + + + + replaces seq {compact.start_session_seq}–{compact.end_session_seq} + + + + {compact.summary} + + + )) + ) : ( + + No context compaction has happened in this session. + + )} + + + + EFFECTS + {runtime.effects.length ? ( + runtime.effects.map((effect) => ( + + {effect.effect_type} + + {effect.status} + + + )) + ) : ( + + No hook effects recorded in this session. + + )} + + + + + ); +} diff --git a/packages/hooks/src/index.tsx b/packages/hooks/src/index.tsx index 43a211c..da53f20 100644 --- a/packages/hooks/src/index.tsx +++ b/packages/hooks/src/index.tsx @@ -20,19 +20,24 @@ import { type Session, type SessionMessage, type SessionProjection, + type SessionRuntimeSnapshot, type TimelineItem, type TurnGroup, } from "@mini-stim/mqueue"; export type { + Compact, MessageConnectionState, MessagePart, MqueueError, PubAck, SantiMqueue, Session, + SessionEffect, SessionMessage, SessionProjection, + SessionRuntimeSnapshot, + SoulSession, TimelineItem, Turn, TurnGroup, @@ -50,6 +55,7 @@ interface SessionActions { create(): PubAck; get(sessionId: string): PubAck; list(): PubAck; + refreshRuntime(sessionId: string): PubAck; select(sessionId: string | null): PubAck; selectAndGet(sessionId: string): PubAck[]; send(input: { sessionId?: string | null; content: MessagePart[] }): PubAck; @@ -134,6 +140,17 @@ export function useSessionTurnTimeline(sessionId?: string | null): TurnGroup[] { return projection.turnTimelineBySessionId[resolvedSessionId] ?? []; } +export function useSessionRuntime( + sessionId?: string | null, +): SessionRuntimeSnapshot | null { + const projection = useSessionProjection(); + const resolvedSessionId = sessionId ?? projection.selectedSessionId; + if (!resolvedSessionId) { + return null; + } + return projection.runtimeBySessionId[resolvedSessionId] ?? null; +} + export function useSessionPending(): number { return useSessionProjection().pending; } @@ -214,6 +231,8 @@ export function useSessionActions(): SessionActions { create: () => mqueue.session.pub("create"), get: (sessionId: string) => mqueue.session.pub("get", { sessionId }), list: () => mqueue.session.pub("list"), + refreshRuntime: (sessionId: string) => + mqueue.session.pub("runtime", { sessionId }), select: (sessionId: string | null) => mqueue.session.pub("select", { sessionId }), selectAndGet: (sessionId: string) => [ diff --git a/packages/mqueue/src/index.ts b/packages/mqueue/src/index.ts index b166ecc..82449a5 100644 --- a/packages/mqueue/src/index.ts +++ b/packages/mqueue/src/index.ts @@ -51,6 +51,7 @@ import type { } from "./types"; export type { + Compact, MessageConnectionState, MessageDeltaPayload, MessageEvent, @@ -64,13 +65,16 @@ export type { SantiWindow, Session, SessionAction, + SessionEffect, SessionEvent, SessionMqueue, SessionMessage, SessionPayloads, SessionPhase, SessionProjection, + SessionRuntimeSnapshot, SessionSubOptions, + SoulSession, TimelineItem, ToolCall, ToolResult, diff --git a/packages/mqueue/src/types.ts b/packages/mqueue/src/types.ts index 9353d6d..1e16ee6 100644 --- a/packages/mqueue/src/types.ts +++ b/packages/mqueue/src/types.ts @@ -11,9 +11,13 @@ import type { } from "@mini-stim/contracts"; export type { + Compact, MessagePart, Session, + SessionEffect, SessionMessage, + SessionRuntimeSnapshot, + SoulSession, ToolCall, ToolResult, Turn, From ad89db2f11653bcbe207c84851b7cd5ab2c7f564 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Thu, 11 Jun 2026 22:42:09 +0800 Subject: [PATCH 4/4] web: use first-message preview for untitled rail rows and add deferred ledger - hooks: useSessionPreviews() exposes the first user-authored line of each loaded session; the rail falls back title -> preview -> raw id, so untitled sessions stop reading as sess_. - README: record core capabilities deliberately not yet surfaced in the web UI (forking, image parts, message edit/delete, soul-view timeline, effects detail), each with its unlock condition, so deferral stays distinguishable from neglect. Verified live: a fresh untitled session shows its raw id until the first send, then the rail row switches to the message preview. --- README.md | 21 ++++++++++++++++ apps/client/soma/web/src/App.tsx | 3 +++ .../soma/web/src/components/SessionRail.tsx | 10 +++++--- packages/hooks/src/index.tsx | 25 +++++++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e4758f9..2150ab3 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,27 @@ Out of scope: semantics, Tauri, and native projections. - Legacy completions or chat completions compatibility. +Modeled in core but deliberately not yet surfaced in the web UI. Each entry +names its unlock condition so deferral stays distinguishable from neglect: + +- Session forking (`parent_session_id`, `fork_point`): modeled and stored, + not exposed over the API. Unlocks when a turn-level "fork from here" + action is wanted; the turn-grouped transcript is the natural anchor. +- Image message parts (`MessagePart::Image`): contracts and rendering + pipeline accept them; the composer is text-only. Unlocks when a concrete + image-input use case shows up in dogfooding. +- Message editing/deletion (`Message.version`, `deleted_at`, + `MessageEvent` audit trail): event-sourced lifecycle exists; the UI + renders latest state only. Unlocks if correction workflows matter more + than transcript immutability. +- Soul-view timeline (`SoulSessionEntry`, dual seq sequences): the inspect + panel shows memory and compact summaries, not the full "what the agent + actually sees" context reconstruction. Unlocks as the inspect panel + matures past its v1 form. +- Effects detail (`SessionEffect` payload/result refs): the inspect panel + lists type and status only. Unlocks when hooks start producing effects + worth debugging in the UI. + ## Setup Create `.env` from `.env.example` and fill: diff --git a/apps/client/soma/web/src/App.tsx b/apps/client/soma/web/src/App.tsx index 574a9ab..999b974 100644 --- a/apps/client/soma/web/src/App.tsx +++ b/apps/client/soma/web/src/App.tsx @@ -7,6 +7,7 @@ import { useSessionActions, useSessionError, useSessionPending, + useSessionPreviews, useSessionRuntime, useSessionTurnTimeline, useSessions, @@ -24,6 +25,7 @@ export function App() { const connection = useMessageConnection(); const actions = useSessionActions(); const runtime = useSessionRuntime(); + const previews = useSessionPreviews(); const [draft, setDraft] = useState(""); const [error, setError] = useState(null); const [inspecting, setInspecting] = useState(false); @@ -146,6 +148,7 @@ export function App() { busy={busy} onCreate={createNewSession} onSelect={selectSession} + previews={previews} selectedSessionId={selectedSessionId} sessions={sessions} /> diff --git a/apps/client/soma/web/src/components/SessionRail.tsx b/apps/client/soma/web/src/components/SessionRail.tsx index d2ae79e..a7cd0d6 100644 --- a/apps/client/soma/web/src/components/SessionRail.tsx +++ b/apps/client/soma/web/src/components/SessionRail.tsx @@ -17,6 +17,7 @@ export function SessionRail(props: { busy: boolean; onCreate: () => void; onSelect: (sessionId: string) => void; + previews: Record; selectedSessionId: string | null; sessions: Session[]; }) { @@ -48,7 +49,7 @@ export function SessionRail(props: { {props.sessions.map((session) => { const selected = session.id === props.selectedSessionId; - const label = sessionLabel(session); + const label = sessionLabel(session, props.previews[session.id]); return (