fix: UX improvement around reasoning text, fixup tool invocation card in Chat UI

This commit is contained in:
David Garson 2026-01-29 00:10:47 -07:00
parent b927361650
commit 45bff6a2e4
20 changed files with 510 additions and 99 deletions

View File

@ -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/<agentId>/.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/<agentId>/sessions/`).
## Steering while streaming

View File

@ -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,

View File

@ -938,7 +938,8 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
...(normalizedName.rawName ? { rawName: normalizedName.rawName } : {}),
...(argsForEvent ? { args: argsForEvent } : {}),
isError,
...(toolText ? { resultText: toolText } : {}),
// Use `result` to match Pi runtime event shape (UI expects `data.result`).
...(toolText ? { result: toolText } : {}),
});
// Emit tool result via callback (using toolText already extracted above).

View File

@ -19,7 +19,6 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { TSchema } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import * as z from "zod/v4-mini";
import { createSubsystemLogger } from "../../logging/subsystem.js";

View File

@ -213,14 +213,6 @@ export type SdkRunnerParams = {
*/
mcpServerName?: string;
/**
* Custom Claude Code config directory for session storage.
* When provided, sets CLAUDE_CONFIG_DIR environment variable to ensure
* Moltbot agent sessions are stored separately from standard Claude Code
* CLI sessions. Typically: ~/.clawdbot/agents/{agentId}/ccsdk/
*/
ccsdkConfigDir?: string;
/**
* Prior conversation history to serialize into the SDK prompt.
* Since the SDK is stateless, prior turns are injected as context

View File

@ -12,8 +12,8 @@ export function logToolEvent(evt: AgentEventData, runId?: string): void {
if (evt.stream !== "tool") return;
const phase = evt.data.phase as string | undefined;
const toolName = String(evt.data.name ?? "unknown");
const toolCallId = String(evt.data.toolCallId ?? "");
const toolName = (evt.data.name as string | undefined) ?? "unknown";
const toolCallId = (evt.data.toolCallId as string | undefined) ?? "";
switch (phase) {
case "start":

View File

@ -71,25 +71,3 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
if (store.startsWith("~")) return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir()));
return path.resolve(store);
}
/**
* Resolve the Claude Code SDK config directory for a given agent.
*
* This directory is passed to the SDK via the CLAUDE_CONFIG_DIR environment
* variable to ensure Moltbot agent sessions are stored separately from
* standard Claude Code CLI sessions.
*
* Using `.claude/` allows per-agent customization of other Claude Code
* features like settings.json, CLAUDE.md, skills, commands, etc.
*
* Structure: ~/.clawdbot/agents/{agentId}/.claude/
*/
export function resolveCcSdkConfigDirForAgent(
agentId?: string,
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const root = resolveStateDir(env, homedir);
const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID);
return path.join(root, "agents", id, ".claude");
}

View File

@ -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<string, PhaseBuffer>;
/** @deprecated Use phaseBuffers for phase-aware rendering. This provides combined text for backward compatibility. */
buffers: Map<string, string>;
deltaSentAt: Map<string, number>;
abortedRuns: Map<string, number>;
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<string, string>();
const phaseBuffers = new Map<string, PhaseBuffer>();
const deltaSentAt = new Map<string, number>();
const abortedRuns = new Map<string, number>();
// Compatibility layer: buffers provides combined text from phaseBuffers
// This allows existing code that expects Map<string, string> to continue working.
const buffers = new Proxy(new Map<string, string>(), {
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);

View File

@ -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);
}

View File

@ -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);

View File

@ -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<typeof resetToolStream>[0]);
void loadAssistantIdentity(host as unknown as MoltbotApp);

View File

@ -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,

View File

@ -20,6 +20,7 @@ export type ToolStreamEntry = {
name: string;
args?: unknown;
output?: string;
isError?: boolean;
startedAt: number;
updatedAt: number;
message: Record<string, unknown>;
@ -82,11 +83,12 @@ function buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown>
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;
}

View File

@ -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;

View File

@ -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;

View File

@ -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`
<div class="chat-group assistant">
@ -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,
)}
<div class="chat-group-footer">
@ -224,6 +263,47 @@ function renderMessageImages(images: ImageBlock[]) {
`;
}
function hasMultiplePhaseTypes(content: unknown[]): boolean {
let hasThinking = false;
let hasText = false;
for (const item of content) {
if (typeof item !== "object" || item === null) continue;
const c = item as { type?: string };
if (c.type === "thinking") hasThinking = true;
if (c.type === "text") hasText = true;
if (hasThinking && hasText) return true;
}
return false;
}
function renderPhaseContent(
content: unknown[],
opts: { showReasoning: boolean },
) {
// Check if we have multiple phase types for visual separation
const usePhaseClasses = hasMultiplePhaseTypes(content);
return content.map((item) => {
if (typeof item !== "object" || item === null) return nothing;
const c = item as { type?: string; thinking?: string; text?: string };
if (c.type === "thinking" && typeof c.thinking === "string" && opts.showReasoning) {
const reasoningMarkdown = formatReasoningMarkdown(c.thinking);
const className = usePhaseClasses ? "chat-thinking-phase" : "chat-thinking";
return html`<div class="${className}">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`;
}
if (c.type === "text" && typeof c.text === "string") {
const className = usePhaseClasses ? "chat-text-phase" : "chat-text";
return html`<div class="${className}">${unsafeHTML(toSanitizedMarkdownHtml(c.text))}</div>`;
}
return nothing;
});
}
function renderGroupedMessage(
message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean },
@ -243,6 +323,19 @@ function renderGroupedMessage(
const images = extractImages(message);
const hasImages = images.length > 0;
// Check if content is already phase-structured (for streaming)
const content = m.content;
const isPhaseStructured =
Array.isArray(content) &&
content.length > 0 &&
content.some(
(item) =>
typeof item === "object" &&
item !== null &&
((item as { type?: string }).type === "thinking" ||
(item as { type?: string }).type === "text"),
);
const extractedText = extractTextCached(message);
const extractedThinking =
opts.showReasoning && role === "assistant"
@ -272,6 +365,19 @@ function renderGroupedMessage(
if (!markdown && !hasToolCards && !hasImages) return nothing;
// Use phase-aware rendering when content is structured with phases
if (isPhaseStructured && Array.isArray(content)) {
return html`
<div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
${renderMessageImages(images)}
${renderPhaseContent(content, { showReasoning: opts.showReasoning })}
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div>
`;
}
// Fallback to legacy rendering
return html`
<div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}

View File

@ -35,7 +35,8 @@ export function extractToolCards(message: unknown): ToolCard[] {
if (kind !== "toolresult" && kind !== "tool_result") continue;
const text = extractToolText(item);
const name = typeof item.name === "string" ? item.name : "tool";
cards.push({ kind: "result", name, text });
const isError = item.isError === true;
cards.push({ kind: "result", name, text, isError });
}
if (
@ -60,17 +61,22 @@ export function renderToolCardSidebar(
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const hasText = Boolean(card.text?.trim());
const isError = card.isError === true;
const canClick = Boolean(onOpenSidebar);
const handleClick = canClick
? () => {
if (hasText) {
onOpenSidebar!(formatToolOutputForSidebar(card.text!));
const prefix = isError ? "## Error\n\n" : "";
onOpenSidebar!(prefix + formatToolOutputForSidebar(card.text!));
return;
}
const status = isError
? "*Tool failed with no error message.*"
: "*No output — tool completed successfully.*";
const info = `## ${display.label}\n\n${
detail ? `**Command:** \`${detail}\`\n\n` : ""
}*No output tool completed successfully.*`;
}${status}`;
onOpenSidebar!(info);
}
: undefined;
@ -80,9 +86,12 @@ export function renderToolCardSidebar(
const showInline = hasText && isShort;
const isEmpty = !hasText;
const statusIcon = isError ? icons.x : icons.check;
const statusClass = isError ? "chat-tool-card--error" : "";
return html`
<div
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""}"
class="chat-tool-card ${canClick ? "chat-tool-card--clickable" : ""} ${statusClass}"
@click=${handleClick}
role=${canClick ? "button" : nothing}
tabindex=${canClick ? "0" : nothing}
@ -100,21 +109,21 @@ export function renderToolCardSidebar(
<span>${display.label}</span>
</div>
${canClick
? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${icons.check}</span>`
? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${statusIcon}</span>`
: nothing}
${isEmpty && !canClick ? html`<span class="chat-tool-card__status">${icons.check}</span>` : nothing}
${isEmpty && !canClick ? html`<span class="chat-tool-card__status">${statusIcon}</span>` : nothing}
</div>
${detail
? html`<div class="chat-tool-card__detail">${detail}</div>`
: nothing}
${isEmpty
? html`<div class="chat-tool-card__status-text muted">Completed</div>`
? html`<div class="chat-tool-card__status-text ${isError ? "error" : "muted"}">${isError ? "Failed" : "Completed"}</div>`
: nothing}
${showCollapsed
? html`<div class="chat-tool-card__preview mono">${getTruncatedPreview(card.text!)}</div>`
? html`<div class="chat-tool-card__preview mono ${isError ? "error" : ""}">${getTruncatedPreview(card.text!)}</div>`
: nothing}
${showInline
? html`<div class="chat-tool-card__inline mono">${card.text}</div>`
? html`<div class="chat-tool-card__inline mono ${isError ? "error" : ""}">${card.text}</div>`
: nothing}
</div>
`;

View File

@ -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<boolean> {
}
}
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";

View File

@ -40,4 +40,5 @@ export type ToolCard = {
name: string;
args?: unknown;
text?: string;
isError?: boolean;
};

View File

@ -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<ChatItem | MessageGroup> {
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,