diff --git a/docs/concepts/agent.md b/docs/concepts/agent.md index 4f0bd3bd4..561da3454 100644 --- a/docs/concepts/agent.md +++ b/docs/concepts/agent.md @@ -76,24 +76,27 @@ Legacy Pi/Tau session folders are **not** read. ## Claude Agent SDK Runtime -When using the Claude Agent SDK (`ccsdk`) runtime, Moltbot stores SDK-specific -configuration and sessions in a per-agent `.claude` directory: +When using the Claude Agent SDK (`ccsdk`) runtime, authentication is handled +automatically through the SDK's internal auth resolution mechanism. By default, +the SDK uses OS-managed credentials (the same credentials used by Claude Code CLI). -``` -~/.clawdbot/agents//.claude/ -├── sessions/ # SDK native session files -├── settings.json # Per-agent SDK settings (optional) -├── CLAUDE.md # Per-agent memory/instructions (optional) -├── skills/ # Per-agent skills (optional) -└── commands/ # Per-agent slash commands (optional) +Alternatively, you can provide an explicit `ANTHROPIC_API_KEY` in your per-agent +provider configuration in `moltbot.json`: + +```json5 +{ + "agents": { + "main": { + "provider": { + "env": { "ANTHROPIC_API_KEY": "sk-ant-..." } + } + } + } +} ``` -This separation ensures Moltbot agent sessions are stored separately from -standard Claude Code CLI sessions (`~/.claude/`), and allows per-agent -customization of Claude Code features like settings, skills, and commands. - -The SDK config directory is set via the `CLAUDE_CONFIG_DIR` environment variable -when launching the SDK process. +Session transcripts for SDK-based runs are stored in Moltbot's standard +session directory (`~/.clawdbot/agents//sessions/`). ## Steering while streaming diff --git a/src/agents/claude-agent-sdk/sdk-agent-runtime.ts b/src/agents/claude-agent-sdk/sdk-agent-runtime.ts index 8360f8bba..d3fb8a505 100644 --- a/src/agents/claude-agent-sdk/sdk-agent-runtime.ts +++ b/src/agents/claude-agent-sdk/sdk-agent-runtime.ts @@ -24,7 +24,6 @@ import type { SdkConversationTurn, SdkRunnerResult } from "./types.js"; import { createMoltbotCodingTools } from "../pi-tools.js"; import { resolveMoltbotAgentDir } from "../agent-paths.js"; import { resolveModelAuthMode } from "../model-auth.js"; -import { resolveCcSdkConfigDirForAgent } from "../../config/sessions/paths.js"; import { convertClientToolsForSdk } from "./client-tool-bridge.js"; const log = createSubsystemLogger("agents/ccsdk-runtime"); @@ -210,7 +209,7 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age const ccsdkOpts = params.ccsdkOptions ?? {}; // Resolve effective model (from params or provider config) - const effectiveModel = params.model + const _effectiveModel = params.model ? `${params.provider ?? "anthropic"}/${params.model}` : (ccsdkOpts.modelTiers?.sonnet ?? context?.ccsdkConfig?.models?.sonnet ?? "default"); @@ -266,10 +265,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age // Combine built-in tools with client tools const tools: AnyAgentTool[] = [...builtInTools, ...clientToolsConverted]; - // Resolve CCSDK config directory for this agent's session storage. - // This ensures Moltbot sessions are stored separately from CLI sessions. - const ccsdkConfigDir = resolveCcSdkConfigDirForAgent(agentId); - const sdkResult = await runSdkAgent({ // ─── Core shared params (spread) ──────────────────────────────────────── runId: params.runId, @@ -284,7 +279,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age timeoutMs: params.timeoutMs, abortSignal: params.abortSignal, extraSystemPrompt: params.extraSystemPrompt, - ccsdkConfigDir, // ─── Messaging & sender context (shared) ──────────────────────────────── messageChannel: params.messageChannel, diff --git a/src/agents/claude-agent-sdk/sdk-runner.ts b/src/agents/claude-agent-sdk/sdk-runner.ts index 5750b76d0..1ee9fc98f 100644 --- a/src/agents/claude-agent-sdk/sdk-runner.ts +++ b/src/agents/claude-agent-sdk/sdk-runner.ts @@ -938,7 +938,8 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise string = os.homedir, -): string { - const root = resolveStateDir(env, homedir); - const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID); - return path.join(root, "agents", id, ".claude"); -} diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 8c67767a6..e973765c4 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -28,6 +28,16 @@ export type ChatRunEntry = { clientRunId: string; }; +export type StreamPhase = { + type: "thinking" | "text"; + content: string; +}; + +export type PhaseBuffer = { + phases: StreamPhase[]; + lastPhaseType: "thinking" | "text" | null; +}; + export type ChatRunRegistry = { add: (sessionId: string, entry: ChatRunEntry) => void; peek: (sessionId: string) => ChatRunEntry | undefined; @@ -80,27 +90,114 @@ export function createChatRunRegistry(): ChatRunRegistry { export type ChatRunState = { registry: ChatRunRegistry; + phaseBuffers: Map; + /** @deprecated Use phaseBuffers for phase-aware rendering. This provides combined text for backward compatibility. */ buffers: Map; deltaSentAt: Map; abortedRuns: Map; clear: () => void; }; +function createPhaseBuffer(): PhaseBuffer { + return { phases: [], lastPhaseType: null }; +} + +function appendToPhaseBuffer( + buffer: PhaseBuffer, + type: "thinking" | "text", + content: string, +): void { + if (buffer.lastPhaseType === type && buffer.phases.length > 0) { + // Same phase - append + buffer.phases[buffer.phases.length - 1].content += content; + } else { + // New phase + buffer.phases.push({ type, content }); + buffer.lastPhaseType = type; + } +} + +function getPhaseBufferText(buffer: PhaseBuffer): string { + return buffer.phases + .filter((p) => p.type === "text") + .map((p) => p.content) + .join(""); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- reserved for future use +function _getPhaseBufferThinking(buffer: PhaseBuffer): string { + return buffer.phases + .filter((p) => p.type === "thinking") + .map((p) => p.content) + .join(""); +} + export function createChatRunState(): ChatRunState { const registry = createChatRunRegistry(); - const buffers = new Map(); + const phaseBuffers = new Map(); const deltaSentAt = new Map(); const abortedRuns = new Map(); + // Compatibility layer: buffers provides combined text from phaseBuffers + // This allows existing code that expects Map to continue working. + const buffers = new Proxy(new Map(), { + get(target, prop, receiver) { + if (prop === "get") { + return (key: string) => { + const buffer = phaseBuffers.get(key); + return buffer ? getPhaseBufferText(buffer) : undefined; + }; + } + if (prop === "has") { + return (key: string) => phaseBuffers.has(key); + } + if (prop === "delete") { + return (key: string) => phaseBuffers.delete(key); + } + if (prop === "clear") { + return () => phaseBuffers.clear(); + } + if (prop === "size") { + return phaseBuffers.size; + } + if (prop === "keys") { + return () => phaseBuffers.keys(); + } + if (prop === "values") { + return function* () { + for (const buffer of phaseBuffers.values()) { + yield getPhaseBufferText(buffer); + } + }; + } + if (prop === "entries") { + return function* () { + for (const [key, buffer] of phaseBuffers.entries()) { + yield [key, getPhaseBufferText(buffer)] as [string, string]; + } + }; + } + if (prop === Symbol.iterator) { + return function* () { + for (const [key, buffer] of phaseBuffers.entries()) { + yield [key, getPhaseBufferText(buffer)] as [string, string]; + } + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + const clear = () => { registry.clear(); - buffers.clear(); + phaseBuffers.clear(); deltaSentAt.clear(); abortedRuns.clear(); }; return { registry, + phaseBuffers, buffers, deltaSentAt, abortedRuns, @@ -133,12 +230,30 @@ export function createAgentEventHandler({ resolveSessionKeyForRun, clearAgentRunContext, }: AgentEventHandlerOptions) { + const buildPhaseContent = ( + buffer: PhaseBuffer, + ): Array<{ type: string; thinking?: string; text?: string; phaseIndex?: number }> => { + return buffer.phases.map((phase, idx) => ({ + type: phase.type, + [phase.type === "thinking" ? "thinking" : "text"]: phase.content, + phaseIndex: idx, + })); + }; + const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => { - chatRunState.buffers.set(clientRunId, text); + let buffer = chatRunState.phaseBuffers.get(clientRunId); + if (!buffer) { + buffer = createPhaseBuffer(); + chatRunState.phaseBuffers.set(clientRunId, buffer); + } + appendToPhaseBuffer(buffer, "text", text); + const now = Date.now(); const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0; if (now - last < 150) return; chatRunState.deltaSentAt.set(clientRunId, now); + + const content = buildPhaseContent(buffer); const payload = { runId: clientRunId, sessionKey, @@ -146,7 +261,34 @@ export function createAgentEventHandler({ state: "delta" as const, message: { role: "assistant", - content: [{ type: "text", text }], + content, + timestamp: now, + }, + }; + // Suppress webchat broadcast for heartbeat runs when showOk is false + if (!shouldSuppressHeartbeatBroadcast(clientRunId)) { + broadcast("chat", payload, { dropIfSlow: true }); + } + nodeSendToSession(sessionKey, "chat", payload); + }; + + const emitThinkingDelta = (sessionKey: string, clientRunId: string, seq: number) => { + const buffer = chatRunState.phaseBuffers.get(clientRunId); + if (!buffer || buffer.phases.length === 0) return; + const now = Date.now(); + const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0; + if (now - last < 150) return; + chatRunState.deltaSentAt.set(clientRunId, now); + + const content = buildPhaseContent(buffer); + const payload = { + runId: clientRunId, + sessionKey, + seq, + state: "delta" as const, + message: { + role: "assistant", + content, timestamp: now, }, }; @@ -164,19 +306,40 @@ export function createAgentEventHandler({ jobState: "done" | "error", error?: unknown, ) => { - const text = chatRunState.buffers.get(clientRunId)?.trim() ?? ""; - chatRunState.buffers.delete(clientRunId); + const buffer = chatRunState.phaseBuffers.get(clientRunId); + chatRunState.phaseBuffers.delete(clientRunId); chatRunState.deltaSentAt.delete(clientRunId); + if (jobState === "done") { + // Build final content from phases, trimming each phase's content + const content: Array<{ + type: string; + thinking?: string; + text?: string; + phaseIndex?: number; + }> = []; + if (buffer) { + for (let idx = 0; idx < buffer.phases.length; idx++) { + const phase = buffer.phases[idx]; + const trimmed = phase.content.trim(); + if (!trimmed) continue; + content.push({ + type: phase.type, + [phase.type === "thinking" ? "thinking" : "text"]: trimmed, + phaseIndex: idx, + }); + } + } + const hasContent = content.length > 0; const payload = { runId: clientRunId, sessionKey, seq, state: "final" as const, - message: text + message: hasContent ? { role: "assistant", - content: [{ type: "text", text }], + content, timestamp: Date.now(), } : undefined, @@ -224,8 +387,12 @@ export function createAgentEventHandler({ // Include sessionKey so Control UI can filter tool streams per session. const agentPayload = sessionKey ? { ...evt, sessionKey } : evt; const last = agentRunSeq.get(evt.runId) ?? 0; - if (evt.stream === "tool" && !shouldEmitToolEvents(evt.runId, sessionKey)) { + const emitToolToNode = shouldEmitToolEvents(evt.runId, sessionKey); + if (evt.stream === "tool" && !emitToolToNode) { agentRunSeq.set(evt.runId, evt.seq); + // Always broadcast tool events to webchat clients - let UI decide visibility. + // Only skip nodeSendToSession for external channels when verbose is off. + broadcast("agent", agentPayload); return; } if (evt.seq !== last + 1) { @@ -249,7 +416,17 @@ export function createAgentEventHandler({ if (sessionKey) { nodeSendToSession(sessionKey, "agent", agentPayload); - if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") { + // Accumulate thinking events and emit thinking-only delta when no text yet + if (!isAborted && evt.stream === "thinking" && typeof evt.data?.text === "string") { + let buffer = chatRunState.phaseBuffers.get(clientRunId); + if (!buffer) { + buffer = createPhaseBuffer(); + chatRunState.phaseBuffers.set(clientRunId, buffer); + } + appendToPhaseBuffer(buffer, "thinking", evt.data.text); + // Emit thinking delta + emitThinkingDelta(sessionKey, clientRunId, evt.seq); + } else if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") { emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text); } else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) { if (chatLink) { @@ -277,7 +454,7 @@ export function createAgentEventHandler({ } else if (isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) { chatRunState.abortedRuns.delete(clientRunId); chatRunState.abortedRuns.delete(evt.runId); - chatRunState.buffers.delete(clientRunId); + chatRunState.phaseBuffers.delete(clientRunId); chatRunState.deltaSentAt.delete(clientRunId); if (chatLink) { chatRunState.registry.remove(evt.runId, clientRunId, sessionKey); diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index 13e245de2..1e702505d 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -122,3 +122,36 @@ border-top: 1px solid var(--border); margin: 1em 0; } + +/* ============================================= + PHASE SEPARATORS + Visual separation between thinking and text phases + ============================================= */ + +.chat-thinking-phase { + border-left: 2px solid var(--accent); + padding-left: 0.75rem; + margin-bottom: 0.5rem; + opacity: 0.85; +} + +.chat-text-phase { + margin-bottom: 0.5rem; +} + +/* Add visual separator between phases of different types */ +.chat-thinking-phase + .chat-text-phase, +.chat-text-phase + .chat-thinking-phase { + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border); +} + +:root[data-theme="light"] .chat-thinking-phase { + border-left-color: var(--accent); +} + +:root[data-theme="light"] .chat-thinking-phase + .chat-text-phase, +:root[data-theme="light"] .chat-text-phase + .chat-thinking-phase { + border-top-color: var(--border); +} diff --git a/ui/src/styles/chat/tool-cards.css b/ui/src/styles/chat/tool-cards.css index 052e63dbb..bbbb9b520 100644 --- a/ui/src/styles/chat/tool-cards.css +++ b/ui/src/styles/chat/tool-cards.css @@ -114,6 +114,36 @@ margin-top: 4px; } +.chat-tool-card__status-text.error { + color: var(--danger); +} + +/* Error state for tool cards */ +.chat-tool-card--error { + border-color: var(--danger); + background: color-mix(in srgb, var(--danger) 5%, var(--card)); +} + +.chat-tool-card--error:hover { + border-color: var(--danger); + background: color-mix(in srgb, var(--danger) 10%, var(--card)); +} + +.chat-tool-card--error .chat-tool-card__status { + color: var(--danger); +} + +.chat-tool-card--error .chat-tool-card__action { + color: var(--danger); +} + +.chat-tool-card--error .chat-tool-card__preview.error, +.chat-tool-card--error .chat-tool-card__inline.error { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 8%, var(--secondary)); + border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); +} + .chat-tool-card__detail { font-size: 12px; color: var(--muted); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index b2355709c..6aae7d11a 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -131,6 +131,8 @@ export function connectGateway(host: GatewayHost) { // Any in-flight run's final event was lost during the disconnect window. host.chatRunId = null; (host as unknown as { chatStream: string | null }).chatStream = null; + (host as unknown as { chatThinkingStream: string | null }).chatThinkingStream = null; + (host as unknown as { chatStreamPhases: Array<{ type: "thinking" | "text"; content: string }> }).chatStreamPhases = []; (host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null; resetToolStream(host as unknown as Parameters[0]); void loadAssistantIdentity(host as unknown as MoltbotApp); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a088c33ff..49f1fac57 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -433,6 +433,8 @@ export function renderApp(state: AppViewState) { state.chatMessage = ""; state.chatAttachments = []; state.chatStream = null; + state.chatThinkingStream = null; + state.chatStreamPhases = []; state.chatStreamStartedAt = null; state.chatRunId = null; state.chatQueue = []; @@ -456,6 +458,8 @@ export function renderApp(state: AppViewState) { messages: state.chatMessages, toolMessages: state.chatToolMessages, stream: state.chatStream, + thinkingStream: state.chatThinkingStream, + streamPhases: state.chatStreamPhases, streamStartedAt: state.chatStreamStartedAt, draft: state.chatMessage, queue: state.chatQueue, diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts index 2fbe12b7a..30b85ff0b 100644 --- a/ui/src/ui/app-tool-stream.ts +++ b/ui/src/ui/app-tool-stream.ts @@ -20,6 +20,7 @@ export type ToolStreamEntry = { name: string; args?: unknown; output?: string; + isError?: boolean; startedAt: number; updatedAt: number; message: Record; @@ -82,11 +83,12 @@ function buildToolStreamMessage(entry: ToolStreamEntry): Record name: entry.name, arguments: entry.args ?? {}, }); - if (entry.output) { + if (entry.output || entry.isError) { content.push({ type: "toolresult", name: entry.name, - text: entry.output, + text: entry.output ?? (entry.isError ? "Error (no message)" : undefined), + isError: entry.isError, }); } return { @@ -205,12 +207,18 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo const name = typeof data.name === "string" ? data.name : "tool"; const phase = typeof data.phase === "string" ? data.phase : ""; const args = phase === "start" ? data.args : undefined; - const output = - phase === "update" - ? formatToolOutput(data.partialResult) - : phase === "result" - ? formatToolOutput(data.result) - : undefined; + const isError = data.isError === true; + + // Extract output: check for error first (failed tools), then result/partialResult + // Supports both `result` (Pi runtime) and `resultText` (CCSDK) field names. + let output: string | null | undefined; + if (phase === "result" && isError && typeof data.error === "string") { + output = data.error; + } else if (phase === "update") { + output = formatToolOutput(data.partialResult); + } else if (phase === "result") { + output = formatToolOutput(data.result ?? data.resultText); + } const now = Date.now(); let entry = host.toolStreamById.get(toolCallId); @@ -221,7 +229,8 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo sessionKey, name, args, - output, + output: output ?? undefined, + isError, startedAt: typeof payload.ts === "number" ? payload.ts : now, updatedAt: now, message: {}, @@ -231,7 +240,8 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo } else { entry.name = name; if (args !== undefined) entry.args = args; - if (output !== undefined) entry.output = output; + if (output !== undefined) entry.output = output ?? undefined; + if (isError) entry.isError = true; entry.updatedAt = now; } diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 069465e32..8beeeb33e 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -53,6 +53,8 @@ export type AppViewState = { chatMessages: unknown[]; chatToolMessages: unknown[]; chatStream: string | null; + chatThinkingStream: string | null; + chatStreamPhases: Array<{ type: "thinking" | "text"; content: string }>; chatRunId: string | null; chatAvatarUrl: string | null; chatThinkingLevel: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index d23e543cd..0d846ec31 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -123,6 +123,8 @@ export class MoltbotApp extends LitElement { @state() chatMessages: unknown[] = []; @state() chatToolMessages: unknown[] = []; @state() chatStream: string | null = null; + @state() chatThinkingStream: string | null = null; + @state() chatStreamPhases: Array<{ type: "thinking" | "text"; content: string }> = []; @state() chatStreamStartedAt: number | null = null; @state() chatRunId: string | null = null; @state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null; diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 4a9ccec14..573548e64 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -70,17 +70,56 @@ export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) { `; } +export type StreamPhase = { + type: "thinking" | "text"; + content: string; +}; + +export type StreamingGroupOptions = { + phases?: StreamPhase[]; + thinking?: string | null; + showReasoning?: boolean; + startedAt: number; +}; + export function renderStreamingGroup( text: string, - startedAt: number, + opts: StreamingGroupOptions, onOpenSidebar?: (content: string) => void, assistant?: AssistantIdentity, ) { - const timestamp = new Date(startedAt).toLocaleTimeString([], { + const timestamp = new Date(opts.startedAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit", }); const name = assistant?.name ?? "Assistant"; + const showReasoning = opts.showReasoning ?? false; + + // Build content array - prefer phases if available for proper visual separation + const content: Array<{ type: string; thinking?: string; text?: string }> = []; + if (opts.phases && opts.phases.length > 0) { + // Use phase-aware rendering + for (const phase of opts.phases) { + if (phase.type === "thinking" && showReasoning) { + content.push({ type: "thinking", thinking: phase.content }); + } else if (phase.type === "text") { + content.push({ type: "text", text: phase.content }); + } + } + } else { + // Fallback to legacy behavior + if (opts.thinking && showReasoning) { + content.push({ type: "thinking", thinking: opts.thinking }); + } + if (text) { + content.push({ type: "text", text }); + } + } + + // If we have no content at all, show reading indicator + if (content.length === 0) { + return renderReadingIndicatorGroup(assistant); + } return html`
@@ -89,10 +128,10 @@ export function renderStreamingGroup( ${renderGroupedMessage( { role: "assistant", - content: [{ type: "text", text }], - timestamp: startedAt, + content, + timestamp: opts.startedAt, }, - { isStreaming: true, showReasoning: false }, + { isStreaming: true, showReasoning }, onOpenSidebar, )} `; diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 6a3e68175..95422a2ba 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -1,8 +1,13 @@ -import { extractText } from "../chat/message-extract"; +import { extractText, extractThinking } from "../chat/message-extract"; import type { GatewayBrowserClient } from "../gateway"; import { generateUUID } from "../uuid"; import type { ChatAttachment } from "../ui-types"; +export type StreamPhase = { + type: "thinking" | "text"; + content: string; +}; + export type ChatState = { client: GatewayBrowserClient | null; connected: boolean; @@ -15,6 +20,8 @@ export type ChatState = { chatAttachments: ChatAttachment[]; chatRunId: string | null; chatStream: string | null; + chatThinkingStream: string | null; + chatStreamPhases: StreamPhase[]; chatStreamStartedAt: number | null; lastError: string | null; }; @@ -155,6 +162,23 @@ export async function abortChatRun(state: ChatState): Promise { } } +function parseStreamPhases(message: unknown): StreamPhase[] | null { + const m = message as { content?: unknown[] } | undefined; + if (!m?.content || !Array.isArray(m.content)) return null; + + const phases: StreamPhase[] = []; + for (const block of m.content) { + if (typeof block !== "object" || block === null) continue; + const b = block as { type?: string; thinking?: string; text?: string }; + if (b.type === "thinking" && typeof b.thinking === "string") { + phases.push({ type: "thinking", content: b.thinking }); + } else if (b.type === "text" && typeof b.text === "string") { + phases.push({ type: "text", content: b.text }); + } + } + return phases.length > 0 ? phases : null; +} + export function handleChatEvent( state: ChatState, payload?: ChatEventPayload, @@ -174,23 +198,57 @@ export function handleChatEvent( } if (payload.state === "delta") { - const next = extractText(payload.message); - if (typeof next === "string") { - const current = state.chatStream ?? ""; - if (!current || next.length >= current.length) { - state.chatStream = next; + // Try to parse phase-aware content first + const phases = parseStreamPhases(payload.message); + if (phases) { + state.chatStreamPhases = phases; + // Also update legacy fields for backward compat + const textPhases = phases.filter((p) => p.type === "text"); + const thinkingPhases = phases.filter((p) => p.type === "thinking"); + if (textPhases.length > 0) { + const combinedText = textPhases.map((p) => p.content).join(""); + const current = state.chatStream ?? ""; + if (!current || combinedText.length >= current.length) { + state.chatStream = combinedText; + } + } + if (thinkingPhases.length > 0) { + state.chatThinkingStream = thinkingPhases.map((p) => p.content).join(""); + } + } else { + // Fallback to legacy extraction + const nextText = extractText(payload.message); + const nextThinking = extractThinking(payload.message); + + // Update text if present and longer + if (typeof nextText === "string") { + const current = state.chatStream ?? ""; + if (!current || nextText.length >= current.length) { + state.chatStream = nextText; + } + } + + // Update thinking if present + if (typeof nextThinking === "string") { + state.chatThinkingStream = nextThinking; } } } else if (payload.state === "final") { state.chatStream = null; + state.chatThinkingStream = null; + state.chatStreamPhases = []; state.chatRunId = null; state.chatStreamStartedAt = null; } else if (payload.state === "aborted") { state.chatStream = null; + state.chatThinkingStream = null; + state.chatStreamPhases = []; state.chatRunId = null; state.chatStreamStartedAt = null; } else if (payload.state === "error") { state.chatStream = null; + state.chatThinkingStream = null; + state.chatStreamPhases = []; state.chatRunId = null; state.chatStreamStartedAt = null; state.lastError = payload.errorMessage ?? "chat error"; diff --git a/ui/src/ui/types/chat-types.ts b/ui/src/ui/types/chat-types.ts index 0638f494d..5e45277fe 100644 --- a/ui/src/ui/types/chat-types.ts +++ b/ui/src/ui/types/chat-types.ts @@ -40,4 +40,5 @@ export type ToolCard = { name: string; args?: unknown; text?: string; + isError?: boolean; }; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index f5fb6e80b..cf99b36c7 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -13,6 +13,7 @@ import { renderMessageGroup, renderReadingIndicatorGroup, renderStreamingGroup, + type StreamPhase, } from "../chat/grouped-render"; import { renderMarkdownSidebar } from "./markdown-sidebar"; import "../components/resizable-divider"; @@ -35,6 +36,8 @@ export type ChatProps = { messages: unknown[]; toolMessages: unknown[]; stream: string | null; + thinkingStream: string | null; + streamPhases?: StreamPhase[]; streamStartedAt: number | null; assistantAvatarUrl?: string | null; draft: string; @@ -219,7 +222,12 @@ export function renderChat(props: ChatProps) { if (item.kind === "stream") { return renderStreamingGroup( item.text, - item.startedAt, + { + phases: props.streamPhases, + thinking: props.thinkingStream, + showReasoning, + startedAt: item.startedAt, + }, props.onOpenSidebar, assistantIdentity, ); @@ -456,7 +464,9 @@ function buildChatItems(props: ChatProps): Array { if (props.stream !== null) { const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`; - if (props.stream.trim().length > 0) { + const hasTextContent = props.stream.trim().length > 0; + const hasPhaseContent = props.streamPhases && props.streamPhases.length > 0; + if (hasTextContent || hasPhaseContent) { items.push({ kind: "stream", key,