diff --git a/api/src/@types/openclaw.ts b/api/src/@types/openclaw.ts index 77940c2..a8b1368 100644 --- a/api/src/@types/openclaw.ts +++ b/api/src/@types/openclaw.ts @@ -4,12 +4,29 @@ export interface SseEmitter { error: (msg: string) => void; } +export interface ToolStepOutput { + text: string; + isError: boolean; + status?: string | null; + exitCode?: number | null; + durationMs?: number | null; + truncated?: boolean; +} + +export interface ToolStep { + id: string; + name: string; + input: Record | null; + output: ToolStepOutput | null; +} + export interface OpenClawMessage { externalId: string; role: string; text: string; thinking: string | null; timestamp: string | null; + toolSteps: ToolStep[] | null; } export interface OpenClawSession { @@ -31,6 +48,18 @@ export interface SessionEntry { fastMode?: boolean | null; verboseLevel?: string | null; reasoningLevel?: string | null; + status?: string; + abortedLastRun?: boolean; + abortReason?: string; + lastInteractionAt?: number; + endedAt?: number; +} + +export interface SessionRunStatus { + aborted: boolean; + status: string | null; + reason: string | null; + endedAt: number | null; } export type SessionsFile = Record; @@ -47,12 +76,23 @@ export interface JsonlThinkingPart { thinking: string; } +export interface JsonlToolCallPart { + type: 'toolCall'; + id?: string; + name?: string; + arguments?: unknown; +} + export interface JsonlOtherPart { type: string; [key: string]: unknown; } -export type JsonlContentPart = JsonlTextPart | JsonlThinkingPart | JsonlOtherPart; +export type JsonlContentPart = + | JsonlTextPart + | JsonlThinkingPart + | JsonlToolCallPart + | JsonlOtherPart; export interface JsonlMessageEntry { type: 'message'; diff --git a/api/src/entities/Message.ts b/api/src/entities/Message.ts index 32baacc..57990a7 100644 --- a/api/src/entities/Message.ts +++ b/api/src/entities/Message.ts @@ -1,4 +1,5 @@ import { Entity, PrimaryGeneratedColumn, Column, DeleteDateColumn, Index } from 'typeorm'; +import { ToolStep } from '../@types/openclaw'; @Entity('messages') @Index(['conversationId', 'externalId'], { unique: true, where: 'externalId IS NOT NULL' }) @@ -21,6 +22,9 @@ export default class Message { @Column({ type: 'simple-json', default: '[]' }) files: { filename: string; originalName: string; mimetype: string; size: number; url: string }[]; + @Column({ type: 'simple-json', nullable: true, default: null }) + toolSteps: ToolStep[] | null; + @Column({ type: 'text', default: 'user' }) role: 'user' | 'assistant'; diff --git a/api/src/routes/agent/controller.ts b/api/src/routes/agent/controller.ts index e09af3f..ca3547c 100644 --- a/api/src/routes/agent/controller.ts +++ b/api/src/routes/agent/controller.ts @@ -267,6 +267,7 @@ const sync: RequestHandler = async (req, res, next) => { externalId: m.externalId, text: m.text, thinking: m.thinking || null, + toolSteps: m.toolSteps && m.toolSteps.length > 0 ? m.toolSteps : null, files: [], role: m.role as MessageRole, createdBy: req.user!._id, diff --git a/api/src/routes/gateway/controller.ts b/api/src/routes/gateway/controller.ts new file mode 100644 index 0000000..80a8ceb --- /dev/null +++ b/api/src/routes/gateway/controller.ts @@ -0,0 +1,25 @@ +/* The other route modules in this folder export multiple handlers, so + * `import * as controller` reads naturally there. This one just exposes + * a single GET — keeping the same pattern for consistency rather than + * switching to a default export. */ +/* eslint-disable import/prefer-default-export */ +import { RequestHandler } from 'express'; +import { gateway } from '../../services/openclawGateway'; + +/** + * GET /api/gateway/status — cheap, unauthenticated snapshot of the + * WebSocket connection to the OpenClaw daemon. Polled by the chat UI + * (5 s cadence) to render the green/yellow/red dot in the message input. + * + * Returning unauthenticated because the response carries no PII (just a + * connection state enum + counter) and the chat shell needs it before + * the user is necessarily logged in to render a useful "gateway down" + * cue. If we ever add detailed timing/credentials in here, gate it. + */ +export const status: RequestHandler = (_req, res, next) => { + try { + return res.json(gateway.getStatus()); + } catch (error) { + return next(error); + } +}; diff --git a/api/src/routes/gateway/index.ts b/api/src/routes/gateway/index.ts new file mode 100644 index 0000000..72ae64b --- /dev/null +++ b/api/src/routes/gateway/index.ts @@ -0,0 +1,8 @@ +import Router from 'express'; +import * as controller from './controller'; + +const router = Router(); + +router.route('/gateway/status').get(controller.status); + +export default router; diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index 6ca589e..4a76902 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -9,6 +9,7 @@ import plugin from './plugin'; import skill from './skill'; import cron from './cron'; import update from './update'; +import gateway from './gateway'; const router = Router(); @@ -22,5 +23,6 @@ router.use(skill); router.use(cron); router.use(auth); router.use(update); +router.use(gateway); export default router; diff --git a/api/src/routes/message/controller.ts b/api/src/routes/message/controller.ts index f207936..602a1ea 100644 --- a/api/src/routes/message/controller.ts +++ b/api/src/routes/message/controller.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import { RequestHandler } from 'express'; -import { LessThan, IsNull, MoreThan, FindOptionsWhere } from 'typeorm'; +import { LessThan, IsNull, In, MoreThan, FindOptionsWhere } from 'typeorm'; import AppDataSource from '../../data-source'; import { Message, Conversation, Agent } from '../../entities'; import { @@ -217,13 +217,18 @@ const chat: Chat = async (req, res, next) => { const assistantThinking = lastAssistantJsonl.thinking ? stripWrapperTags(lastAssistantJsonl.thinking).trim() : null; + const assistantToolSteps = lastAssistantJsonl.toolSteps ?? null; - if (assistantText || assistantThinking) { + /* Persist if we got any signal: real text, thinking, or tool calls + * — the last produces a compact tool-stub bubble in the UI. */ + if (assistantText || assistantThinking || (assistantToolSteps && assistantToolSteps.length > 0)) { const assistantMessage = msgRepo.create({ conversationId: Number(conversationId), externalId: lastAssistantJsonl.externalId || null, - text: assistantText || '...', + text: assistantText, thinking: assistantThinking || null, + toolSteps: + assistantToolSteps && assistantToolSteps.length > 0 ? assistantToolSteps : null, role: 'assistant' as const, createdBy: req.user!._id, createdAt: new Date(), @@ -283,14 +288,18 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?: const conv = await convRepo.findOneBy({ _id: convId }); if (!conv?.sessionKey) { - return res.json({ items: [], synced: 0 }); + return res.json({ items: [], synced: 0, runStatus: null }); } const agent = await agentRepo.findOneBy({ _id: conv.agentId }); if (!agent?.openclawAgentId) { - return res.json({ items: [], synced: 0 }); + return res.json({ items: [], synced: 0, runStatus: null }); } + /* Run state surfaced to the UI so it can show a banner when the daemon + * aborted the last run (model idle timeout, error, user cancel). */ + const runStatus = ocService.getSessionRunStatus(agent.openclawAgentId, conv.sessionKey); + let synced = 0; const jsonlMessages = ocService .getSessionMessages(agent.openclawAgentId, conv.sessionKey) @@ -356,20 +365,47 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?: }); const toInsert: typeof candidates = []; - const updates: Array<{ id: number; externalId: string }> = []; + const updates: Array<{ + id: number; + externalId: string; + thinking: string | null; + toolSteps: NonNullable<(typeof candidates)[number]['toolSteps']> | null; + }> = []; candidates.forEach((m) => { const pool = unlinkedByRole.get(m.role); if (pool && pool.length > 0) { const match = pool.shift()!; - updates.push({ id: match._id, externalId: m.externalId! }); + /* The chat handler may have saved this row pre-stream-completion, + * before any toolResult had landed in JSONL. We carry the live + * toolSteps + thinking through the link so the row picks up + * whatever the JSONL has now (including populated tool outputs). */ + updates.push({ + id: match._id, + externalId: m.externalId!, + thinking: m.thinking || null, + toolSteps: m.toolSteps && m.toolSteps.length > 0 ? m.toolSteps : null, + }); } else { toInsert.push(m); } }); if (updates.length) { - await Promise.all(updates.map((u) => msgRepo.update(u.id, { externalId: u.externalId }))); + await Promise.all( + updates.map((u) => { + /* TypeORM's update() type widens to _QueryDeepPartialEntity, + * which doesn't model arbitrary `Record` shapes + * inside ToolStep.input. The runtime contract is just + * "JSON-serialisable patch", so the cast is safe. */ + const patch = { + externalId: u.externalId, + thinking: u.thinking, + toolSteps: u.toolSteps, + } as unknown as Parameters[1]; + return msgRepo.update(u.id, patch); + }) + ); synced += updates.length; } @@ -381,6 +417,7 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?: externalId: m.externalId, text: m.text, thinking: m.thinking || null, + toolSteps: m.toolSteps && m.toolSteps.length > 0 ? m.toolSteps : null, files: [], role: m.role as 'user' | 'assistant', createdBy: req.user!._id, @@ -390,6 +427,50 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?: ); synced += toInsert.length; } + + /* Refresh tool steps on already-linked assistant rows. A long-running + * tool finishes AFTER its parent assistant turn was first synced, so + * the toolResult lands in JSONL on a later poll. Without this pass, + * the row keeps `output: null` forever and the UI shows "(no result + * captured)". We compare canonical JSON to skip no-op writes. */ + const liveAssistantSteps = jsonlMessages.filter( + (m) => m.role === 'assistant' && m.toolSteps && m.toolSteps.length > 0 + ); + if (liveAssistantSteps.length) { + const liveIds = liveAssistantSteps.map((m) => m.externalId!).filter(Boolean); + if (liveIds.length) { + const existing = await msgRepo.find({ + where: { + conversationId: convId, + role: 'assistant', + externalId: In(liveIds), + }, + select: ['_id', 'externalId', 'toolSteps'], + }); + const dbByExt = new Map(existing.map((m) => [m.externalId!, m])); + const refreshes: Array<{ id: number; toolSteps: typeof liveAssistantSteps[number]['toolSteps'] }> = []; + liveAssistantSteps.forEach((m) => { + const row = dbByExt.get(m.externalId!); + if (!row) return; + const liveJson = JSON.stringify(m.toolSteps ?? null); + const dbJson = JSON.stringify(row.toolSteps ?? null); + if (liveJson !== dbJson) { + refreshes.push({ id: row._id, toolSteps: m.toolSteps }); + } + }); + if (refreshes.length) { + await Promise.all( + refreshes.map((r) => { + const patch = { toolSteps: r.toolSteps } as unknown as Parameters< + typeof msgRepo.update + >[1]; + return msgRepo.update(r.id, patch); + }) + ); + synced += refreshes.length; + } + } + } } const where: FindOptionsWhere = { conversationId: convId }; @@ -403,7 +484,7 @@ const poll: RequestHandler<{ conversationId: string }, unknown, never, { after?: take: 200, }); - return res.json({ items, synced }); + return res.json({ items, synced, runStatus }); } catch (error) { return next(error); } diff --git a/api/src/services/openclaw/index.ts b/api/src/services/openclaw/index.ts index d6eb533..7a498fc 100644 --- a/api/src/services/openclaw/index.ts +++ b/api/src/services/openclaw/index.ts @@ -14,6 +14,7 @@ export { deleteSession, deleteSessionMessage, extractThinkingFromJsonl, + getSessionRunStatus, getSessionSettingsInternal, } from './sessions'; export { runChat } from './chat'; diff --git a/api/src/services/openclaw/jsonlParser.ts b/api/src/services/openclaw/jsonlParser.ts index 43989eb..4c76494 100644 --- a/api/src/services/openclaw/jsonlParser.ts +++ b/api/src/services/openclaw/jsonlParser.ts @@ -4,9 +4,132 @@ import { JsonlEntry, JsonlTextPart, JsonlThinkingPart, + JsonlToolCallPart, OpenClawMessage, + ToolStep, + ToolStepOutput, } from '../../@types/openclaw'; +/** Hard limits to keep tool step JSON small enough to live alongside chat + * messages in SQLite without blowing up message payloads. Real tool I/O + * (e.g. file dumps, large model outputs) routinely runs past these caps; + * the UI shows a "(truncated)" hint when that happens. */ +const MAX_TOOL_INPUT_VALUE_CHARS = 8_000; +const MAX_TOOL_OUTPUT_TEXT_CHARS = 16_000; +/** Provider-specific noise we never want to surface to the UI. */ +const HIDDEN_TOOL_INPUT_KEYS = new Set(['thoughtSignature']); + +function isTextPart(p: JsonlContentPart): p is JsonlTextPart { + return p.type === 'text' && typeof (p as JsonlTextPart).text === 'string'; +} + +function isThinkingPart(p: JsonlContentPart): p is JsonlThinkingPart { + return p.type === 'thinking' && typeof (p as JsonlThinkingPart).thinking === 'string'; +} + +function truncateString(value: string, max: number): { value: string; truncated: boolean } { + if (value.length <= max) return { value, truncated: false }; + return { value: `${value.slice(0, max)}\n…[truncated ${value.length - max} chars]`, truncated: true }; +} + +function sanitizeToolInput(args: unknown): Record | null { + if (!args || typeof args !== 'object' || Array.isArray(args)) return null; + const out: Record = {}; + Object.entries(args as Record).forEach(([k, v]) => { + if (HIDDEN_TOOL_INPUT_KEYS.has(k)) return; + if (typeof v === 'string') { + out[k] = truncateString(v, MAX_TOOL_INPUT_VALUE_CHARS).value; + } else { + /* For nested objects we serialize once + truncate, avoiding deep + * recursion through arbitrary tool schemas. */ + try { + const json = JSON.stringify(v); + if (json && json.length > MAX_TOOL_INPUT_VALUE_CHARS) { + out[k] = `${json.slice(0, MAX_TOOL_INPUT_VALUE_CHARS)}…[truncated]`; + } else { + out[k] = v; + } + } catch { + out[k] = '[unserialisable]'; + } + } + }); + return Object.keys(out).length === 0 ? null : out; +} + +function isToolCallPart(p: JsonlContentPart): p is JsonlToolCallPart { + return ( + Boolean(p) && typeof p === 'object' && (p as { type?: unknown }).type === 'toolCall' + ); +} + +/** + * Walk all JSONL entries once and index `toolResult` rows by their + * `toolCallId`. The map gives us O(1) lookup when assembling assistant + * messages so the parser stays O(N) overall. + */ +function indexToolResults(entries: JsonlEntry[]): Map { + const out = new Map(); + entries.forEach((entry) => { + if (entry.type !== 'message') return; + const msg = entry.message as + | { + role?: string; + toolCallId?: string; + content?: JsonlContentPart[] | string; + isError?: boolean; + details?: Record; + } + | undefined; + if (!msg || msg.role !== 'toolResult' || !msg.toolCallId) return; + + /* The result's primary text lives either as a single string in + * `content` or as a list of text parts. Joining is the safe default. */ + let text = ''; + if (typeof msg.content === 'string') { + text = msg.content; + } else if (Array.isArray(msg.content)) { + text = msg.content + .filter(isTextPart) + .map((c) => c.text) + .join('\n'); + } + const { value: clipped, truncated } = truncateString(text, MAX_TOOL_OUTPUT_TEXT_CHARS); + + const details = (msg.details ?? {}) as { + status?: unknown; + exitCode?: unknown; + durationMs?: unknown; + }; + out.set(msg.toolCallId, { + text: clipped, + isError: msg.isError === true, + status: typeof details.status === 'string' ? details.status : null, + exitCode: typeof details.exitCode === 'number' ? details.exitCode : null, + durationMs: typeof details.durationMs === 'number' ? details.durationMs : null, + truncated, + }); + }); + return out; +} + +function extractToolSteps( + content: JsonlContentPart[] | string, + results: Map +): ToolStep[] { + if (!Array.isArray(content)) return []; + return content.filter(isToolCallPart).map((part) => { + const id = typeof part.id === 'string' ? part.id : ''; + const name = typeof part.name === 'string' ? part.name : 'tool'; + return { + id, + name, + input: sanitizeToolInput(part.arguments), + output: id ? results.get(id) ?? null : null, + }; + }); +} + export function extractUserText(raw: string): string { const trimmed = raw.trim(); // Preserve scheduled-task headers so the frontend can render them specially @@ -29,14 +152,6 @@ export function extractAssistantText(raw: string): string { .trim(); } -function isTextPart(p: JsonlContentPart): p is JsonlTextPart { - return p.type === 'text' && typeof (p as JsonlTextPart).text === 'string'; -} - -function isThinkingPart(p: JsonlContentPart): p is JsonlThinkingPart { - return p.type === 'thinking' && typeof (p as JsonlThinkingPart).thinking === 'string'; -} - function readJsonlLines(jsonlPath: string): JsonlEntry[] { if (!jsonlPath || !fs.existsSync(jsonlPath)) return []; return fs @@ -71,7 +186,13 @@ export function readFirstUserMessage(jsonlPath: string): string | null { export function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] { try { - const raw = readJsonlLines(jsonlPath) + const entries = readJsonlLines(jsonlPath); + /* One pre-pass to index toolResult rows by toolCallId — assistant entries + * reference these by id rather than positionally, so a Map is the only + * reliable way to pair them. */ + const resultsByCallId = indexToolResults(entries); + + const raw = entries .filter((entry) => { if (entry.type !== 'message') return false; const role = entry.message?.role; @@ -97,13 +218,18 @@ export function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] { .join('\n') .trim(); const thinking = [structuredThink, inlineThink].filter(Boolean).join('\n').trim() || null; - if (!text) return null; + const toolSteps = role === 'assistant' ? extractToolSteps(message.content, resultsByCallId) : []; + /* Keep the entry if it has ANY meaningful signal: text, thinking, or + * tool calls. Tool-only assistant turns used to be dropped here, + * which made tool-using runs look like silent gaps in the chat. */ + if (!text && !thinking && toolSteps.length === 0) return null; return { externalId: entry.id || '', role, text, thinking, timestamp: entry.timestamp || null, + toolSteps: toolSteps.length > 0 ? toolSteps : null, }; }) .filter((m): m is OpenClawMessage => m !== null); @@ -113,6 +239,9 @@ export function parseMessagesFromJsonl(jsonlPath: string): OpenClawMessage[] { if (msg.role === 'assistant' && prev?.role === 'assistant') { prev.text += msg.text; if (msg.thinking) prev.thinking = (prev.thinking || '') + msg.thinking; + if (msg.toolSteps && msg.toolSteps.length > 0) { + prev.toolSteps = [...(prev.toolSteps ?? []), ...msg.toolSteps]; + } prev.externalId = msg.externalId; prev.timestamp = msg.timestamp || prev.timestamp; } else { diff --git a/api/src/services/openclaw/sessions.ts b/api/src/services/openclaw/sessions.ts index 396f1f9..8aaa287 100644 --- a/api/src/services/openclaw/sessions.ts +++ b/api/src/services/openclaw/sessions.ts @@ -6,6 +6,7 @@ import { OpenClawMessage, OpenClawSession, SessionEntry, + SessionRunStatus, SessionSettings, SessionSettingsPatchBody, SessionsFile, @@ -77,6 +78,39 @@ export function getSessionSettingsInternal( } } +/** + * Inspect `sessions.json` for the run state of one session. + * + * The OpenClaw daemon writes `status`, `abortedLastRun`, `abortReason`, and + * `endedAt` after each run. When `status === 'timeout'` (model idle timeout) + * or `abortedLastRun === true` (any other abnormal end) the assistant's + * partial reply was streamed to whatever client was connected but never + * committed to the JSONL — so we surface this so the chat UI can explain + * the gap to the user instead of silently showing a missing message. + */ +export function getSessionRunStatus( + agentId: string, + sessionKey: string +): SessionRunStatus { + const empty: SessionRunStatus = { aborted: false, status: null, reason: null, endedAt: null }; + try { + const sessions = readSessions(agentId); + if (!sessions) return empty; + const entry = findSessionEntry(sessions, agentId, sessionKey); + if (!entry) return empty; + const status = typeof entry.status === 'string' ? entry.status : null; + const aborted = entry.abortedLastRun === true || status === 'timeout' || status === 'error'; + const reason = + (typeof entry.abortReason === 'string' && entry.abortReason) || + (status && status !== 'ok' ? status : null) || + null; + const endedAt = typeof entry.endedAt === 'number' ? entry.endedAt : null; + return { aborted, status, reason, endedAt }; + } catch { + return empty; + } +} + export function extractThinkingFromJsonl(agentId: string, sessionKey: string): string | null { try { const sessions = readSessions(agentId); diff --git a/api/src/services/openclawGateway.ts b/api/src/services/openclawGateway.ts index b5376a7..5e9b875 100644 --- a/api/src/services/openclawGateway.ts +++ b/api/src/services/openclawGateway.ts @@ -400,6 +400,39 @@ export class GatewayClient { offEvent(key: string): void { this.eventListeners.delete(key); } + + /** + * Cheap snapshot of the connection's current health, surfaced through + * `/api/gateway/status` for the chat UI's status dot. `connected` means + * the socket is OPEN and post-auth; `connecting` covers both initial + * handshake and reconnect attempts; `disconnected` is everything else. + */ + getStatus(): { + state: 'connected' | 'connecting' | 'disconnected'; + lastSeenAt: number; + reconnectAttempts: number; + hasCredentials: boolean; + } { + const hasCredentials = Boolean(this.credentials || loadGatewayCredentials()); + if (this.ws?.readyState === WsWebSocket.OPEN && this.authenticated) { + return { + state: 'connected', + lastSeenAt: this.lastSeenAt, + reconnectAttempts: 0, + hasCredentials, + }; + } + const connecting = + this.ws?.readyState === WsWebSocket.CONNECTING || + Boolean(this.connectPromise) || + (this.ws?.readyState === WsWebSocket.OPEN && !this.authenticated); + return { + state: connecting ? 'connecting' : 'disconnected', + lastSeenAt: this.lastSeenAt, + reconnectAttempts: this.reconnectAttempts, + hasCredentials, + }; + } } export const gateway = new GatewayClient(); diff --git a/client/src/entities/gateway/api.ts b/client/src/entities/gateway/api.ts new file mode 100644 index 0000000..5c4da01 --- /dev/null +++ b/client/src/entities/gateway/api.ts @@ -0,0 +1,26 @@ +import { baseApi } from '../../shared/api/baseApi'; + +export type GatewayState = 'connected' | 'connecting' | 'disconnected'; + +export interface GatewayStatus { + state: GatewayState; + /** Wall-clock ms of the last byte received on the current socket. 0 + * when never connected. */ + lastSeenAt: number; + /** Successive reconnect attempts since the last successful auth. + * Surfaced for diagnostics, not currently rendered. */ + reconnectAttempts: number; + /** False when device-pairing files are missing — the dot turns into a + * red "needs pairing" cue regardless of socket state. */ + hasCredentials: boolean; +} + +const gatewayApi = baseApi.injectEndpoints({ + endpoints: (build) => ({ + getGatewayStatus: build.query({ + query: () => '/gateway/status', + }), + }), +}); + +export const { useGetGatewayStatusQuery } = gatewayApi; diff --git a/client/src/entities/gateway/index.ts b/client/src/entities/gateway/index.ts new file mode 100644 index 0000000..6499982 --- /dev/null +++ b/client/src/entities/gateway/index.ts @@ -0,0 +1,2 @@ +export * from './api'; +export { default as GatewayStatusDot } from './ui/GatewayStatusDot'; diff --git a/client/src/entities/gateway/ui/GatewayStatusDot.tsx b/client/src/entities/gateway/ui/GatewayStatusDot.tsx new file mode 100644 index 0000000..a883d41 --- /dev/null +++ b/client/src/entities/gateway/ui/GatewayStatusDot.tsx @@ -0,0 +1,57 @@ +import { Box, Tooltip } from '@mui/material'; +import { useGetGatewayStatusQuery } from '../api'; + +const POLL_MS = 5_000; + +const COPY: Record = { + connected: { + color: 'success.main', + tooltip: 'Gateway connected', + }, + connecting: { + color: 'warning.main', + tooltip: 'Connecting to gateway…', + }, + disconnected: { + color: 'error.main', + tooltip: + 'Gateway disconnected — agent traffic is paused. Reconnect attempts are running in the background.', + }, + unpaired: { + color: 'error.main', + tooltip: 'Device not paired with the gateway. Run the pairing flow before sending messages.', + }, +}; + +export default function GatewayStatusDot({ size = 5 }: { size?: number }) { + const { data } = useGetGatewayStatusQuery(undefined, { + pollingInterval: POLL_MS, + refetchOnMountOrArgChange: true, + }); + + const key = data?.hasCredentials === false ? 'unpaired' : (data?.state ?? 'disconnected'); + const meta = COPY[key]; + + return ( + + + + ); +} diff --git a/client/src/entities/message/api.ts b/client/src/entities/message/api.ts index 5acc381..a3a8352 100644 --- a/client/src/entities/message/api.ts +++ b/client/src/entities/message/api.ts @@ -8,11 +8,35 @@ export interface MessageFile { url: string; } +/** Provider-reported result of a single tool invocation. */ +export interface ToolStepOutput { + text: string; + isError: boolean; + status?: string | null; + exitCode?: number | null; + durationMs?: number | null; + truncated?: boolean; +} + +/** + * One tool invocation captured from the assistant's JSONL turn — the + * `toolCall` content part paired with its matching `toolResult` row. The + * UI renders these as collapsible "Tool call / Tool output" blocks beneath + * the thinking section so users can audit what the agent did. + */ +export interface ToolStep { + id: string; + name: string; + input: Record | null; + output: ToolStepOutput | null; +} + export interface Message { _id: string; conversationId: string; text: string; thinking: string | null; + toolSteps?: ToolStep[] | null; files: MessageFile[]; role: 'user' | 'assistant'; createdAt: string; @@ -29,9 +53,24 @@ export interface MessagesQueryArg { before?: string; } +/** + * Run-state surfaced from `sessions.json` on each poll. When `aborted` is + * true the OpenClaw daemon ended the last run abnormally (idle timeout, + * error, manual cancel) and the agent's reply was streamed only — never + * committed to the JSONL. The UI shows a banner explaining this so the + * apparent gap doesn't look like a sync failure. + */ +export interface SessionRunStatus { + aborted: boolean; + status: string | null; + reason: string | null; + endedAt: number | null; +} + export interface PollResponse { items: Message[]; synced: number; + runStatus: SessionRunStatus | null; } export interface PollQueryArg { diff --git a/client/src/entities/message/lib/toolStepFormatting.ts b/client/src/entities/message/lib/toolStepFormatting.ts new file mode 100644 index 0000000..d86bd80 --- /dev/null +++ b/client/src/entities/message/lib/toolStepFormatting.ts @@ -0,0 +1,42 @@ +import type { ToolStep } from '../api'; + +/** + * Pull the most informative one-liner out of a tool call's arguments so + * the collapsed header has context without forcing the user to expand. + * Special-cases the common shapes (exec/read/write/edit) and falls back + * to the first string field for anything else. + */ +export function summarizeInput(name: string, input: Record | null): string { + if (!input) return ''; + const candidate = + (typeof input.command === 'string' && input.command) || + (typeof input.path === 'string' && input.path) || + (typeof input.file_path === 'string' && input.file_path) || + (typeof input.url === 'string' && input.url) || + (typeof input.query === 'string' && input.query) || + (typeof input.pattern === 'string' && input.pattern) || + ''; + if (candidate) return candidate; + /* Generic fallback: first string-valued arg, prefixed with its key so + * the header stays self-describing for unknown tools. */ + const first = Object.entries(input).find(([, v]) => typeof v === 'string' && v); + if (first) return `${first[0]}: ${first[1]}`; + return name; +} + +export function formatDuration(ms: number | null | undefined): string | null { + if (ms == null || !Number.isFinite(ms)) return null; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s`; +} + +/** Roll up per-step statuses into one summary state for the outer header. */ +export function aggregateStatus(steps: ToolStep[]): 'ok' | 'error' | 'pending' { + const hasPending = steps.some((s) => !s.output); + const hasError = steps.some( + (s) => s.output?.isError || (typeof s.output?.exitCode === 'number' && s.output.exitCode !== 0) + ); + if (hasError) return 'error'; + if (hasPending) return 'pending'; + return 'ok'; +} diff --git a/client/src/entities/message/ui/CronMessageBubble.tsx b/client/src/entities/message/ui/CronMessageBubble.tsx index a4a5017..3a8019d 100644 --- a/client/src/entities/message/ui/CronMessageBubble.tsx +++ b/client/src/entities/message/ui/CronMessageBubble.tsx @@ -1,6 +1,22 @@ import { useState, memo, useCallback } from 'react'; -import { Box, Paper, Typography, IconButton, Chip, alpha, useTheme } from '@mui/material'; -import { DeleteOutline, ContentCopy, Done, ScheduleOutlined } from '@mui/icons-material'; +import { + Box, + Paper, + Stack, + Tooltip, + Typography, + IconButton, + Chip, + alpha, + useTheme, +} from '@mui/material'; +import { + DeleteOutline, + ContentCopy, + Done, + ScheduleOutlined, + ErrorOutline, +} from '@mui/icons-material'; import { DeleteButton, MarkdownContent } from '../../../shared/ui'; import { useDeleteMessageMutation, type Message } from '../api'; import type { ParsedCronMessage } from '../lib/parseCronMessage'; @@ -9,12 +25,14 @@ interface CronMessageBubbleProps { message: Message | { text: string; role: string }; messageId?: string; parsed: ParsedCronMessage; + deliveryError?: string | null; } const CronMessageBubble = memo(function CronMessageBubble({ message, messageId, parsed, + deliveryError, }: CronMessageBubbleProps) { const [hovered, setHovered] = useState(false); const [copied, setCopied] = useState(false); @@ -121,13 +139,19 @@ const CronMessageBubble = memo(function CronMessageBubble({ )} - {createdAt && ( - - {new Date(createdAt).toLocaleTimeString()} - + {(createdAt || deliveryError) && ( + + {deliveryError && ( + + + + )} + {createdAt && ( + + {new Date(createdAt).toLocaleTimeString()} + + )} + )} diff --git a/client/src/entities/message/ui/MessageBubble.tsx b/client/src/entities/message/ui/MessageBubble.tsx index 51a7c2d..567a6d2 100644 --- a/client/src/entities/message/ui/MessageBubble.tsx +++ b/client/src/entities/message/ui/MessageBubble.tsx @@ -1,24 +1,32 @@ import { useState, memo, useCallback } from 'react'; -import { Box, Paper, Typography, IconButton, useTheme } from '@mui/material'; -import { DeleteOutline, ContentCopy, Done } from '@mui/icons-material'; +import { Box, Paper, Stack, Tooltip, Typography, IconButton, useTheme } from '@mui/material'; +import { DeleteOutline, ContentCopy, Done, ErrorOutline } from '@mui/icons-material'; import { DeleteButton, MarkdownContent } from '../../../shared/ui'; import ThinkingBlock from './ThinkingBlock'; +import ToolStepsBlock from './ToolStepsBlock'; import FileAttachments from './FileAttachments'; import CronMessageBubble from './CronMessageBubble'; import ChannelMetadataHeader from './ChannelMetadataHeader'; import { parseCronMessage } from '../lib/parseCronMessage'; import { parseChannelMetadata } from '../lib/parseChannelMetadata'; -import { useDeleteMessageMutation, type Message, type MessageFile } from '../api'; +import { useDeleteMessageMutation, type Message, type MessageFile, type ToolStep } from '../api'; export type MessageLike = | Message - | { text: string; role: string; thinking?: string | null; files?: MessageFile[] }; + | { + text: string; + role: string; + thinking?: string | null; + files?: MessageFile[]; + toolSteps?: ToolStep[] | null; + }; interface MessageBubbleProps { message: MessageLike; isStreaming?: boolean; thinkingText?: string; messageId?: string; + deliveryError?: string | null; } const MessageBubble = memo(function MessageBubble({ @@ -26,6 +34,7 @@ const MessageBubble = memo(function MessageBubble({ isStreaming, thinkingText, messageId, + deliveryError, }: MessageBubbleProps) { const [hovered, setHovered] = useState(false); const [copied, setCopied] = useState(false); @@ -35,6 +44,10 @@ const MessageBubble = memo(function MessageBubble({ const isUser = message.role === 'user'; const thinking = thinkingText || ('thinking' in message ? message.thinking : null); const files = ('files' in message ? message.files : undefined) ?? []; + const toolSteps = + !isUser && 'toolSteps' in message && Array.isArray(message.toolSteps) + ? (message.toolSteps as ToolStep[]) + : []; const parsedCron = isUser && !isStreaming ? parseCronMessage(message.text) : null; const parsedChannel = isUser && !isStreaming && !parsedCron ? parseChannelMetadata(message.text) : null; @@ -55,7 +68,38 @@ const MessageBubble = memo(function MessageBubble({ if (parsedCron) { return ( - + + ); + } + + if (!isUser && !hasTextContent && !thinking && toolSteps.length > 0 && !isStreaming) { + return ( + + + {'createdAt' in message && ( + + {new Date((message as Message).createdAt).toLocaleTimeString()} + + )} + ); } @@ -133,6 +177,7 @@ const MessageBubble = memo(function MessageBubble({ ) : ( {displayText} ))} + {!isUser && toolSteps.length > 0 && } {isStreaming && !hasTextContent && ( )} - {'createdAt' in message && ( - - {new Date(message.createdAt).toLocaleTimeString()} - + {('createdAt' in message || deliveryError) && ( + + {deliveryError && ( + + + + )} + {'createdAt' in message && ( + + {new Date(message.createdAt).toLocaleTimeString()} + + )} + )} diff --git a/client/src/entities/message/ui/ToolStepCodeFrame.tsx b/client/src/entities/message/ui/ToolStepCodeFrame.tsx new file mode 100644 index 0000000..38b9c81 --- /dev/null +++ b/client/src/entities/message/ui/ToolStepCodeFrame.tsx @@ -0,0 +1,25 @@ +import { Box } from '@mui/material'; + +export default function ToolStepCodeFrame({ children }: { children: string }) { + return ( + + {children} + + ); +} diff --git a/client/src/entities/message/ui/ToolStepHeader.tsx b/client/src/entities/message/ui/ToolStepHeader.tsx new file mode 100644 index 0000000..a58126b --- /dev/null +++ b/client/src/entities/message/ui/ToolStepHeader.tsx @@ -0,0 +1,78 @@ +import { Box, Chip, Typography } from '@mui/material'; +import { ExpandMore } from '@mui/icons-material'; +import type { ReactNode } from 'react'; + +interface ToolStepHeaderProps { + expanded: boolean; + onToggle: () => void; + label: string; + toolName: string; + summary?: string; + trailing?: ReactNode; +} + +export default function ToolStepHeader({ + expanded, + onToggle, + label, + toolName, + summary, + trailing, +}: ToolStepHeaderProps) { + return ( + + + + {label} + + + {summary && ( + + {summary} + + )} + {trailing && {trailing}} + + ); +} diff --git a/client/src/entities/message/ui/ToolStepPair.tsx b/client/src/entities/message/ui/ToolStepPair.tsx new file mode 100644 index 0000000..1861ac8 --- /dev/null +++ b/client/src/entities/message/ui/ToolStepPair.tsx @@ -0,0 +1,105 @@ +import { useMemo, useState } from 'react'; +import { Box, Collapse, Typography } from '@mui/material'; +import type { ToolStep } from '../api'; +import { summarizeInput } from '../lib/toolStepFormatting'; +import ToolStepCodeFrame from './ToolStepCodeFrame'; +import ToolStepHeader from './ToolStepHeader'; +import ToolStepTrailing from './ToolStepTrailing'; + +interface ToolStepPairProps { + step: ToolStep; + idx: number; +} + +export default function ToolStepPair({ step, idx }: ToolStepPairProps) { + const [callOpen, setCallOpen] = useState(false); + const [outputOpen, setOutputOpen] = useState(false); + + const inputJson = useMemo(() => { + if (!step.input) return ''; + try { + return JSON.stringify(step.input, null, 2); + } catch { + return String(step.input); + } + }, [step.input]); + + const summary = summarizeInput(step.name, step.input); + const out = step.output; + + return ( + + setCallOpen((v) => !v)} + label="Tool call" + toolName={step.name} + summary={summary} + trailing={} + /> + + {step.input ? ( + {inputJson} + ) : ( + + (no arguments) + + )} + + + + setOutputOpen((v) => !v)} + label="Tool output" + toolName={step.name} + /> + + {out ? ( + <> + {out.text ? ( + {out.text} + ) : ( + + (no output) + + )} + {out.truncated && ( + + output truncated for storage + + )} + + ) : ( + + {/* In-flight call (e.g. live stream) or aborted run — no result row exists yet. */} + (no result captured) + + )} + + + + ); +} diff --git a/client/src/entities/message/ui/ToolStepTrailing.tsx b/client/src/entities/message/ui/ToolStepTrailing.tsx new file mode 100644 index 0000000..4cb938a --- /dev/null +++ b/client/src/entities/message/ui/ToolStepTrailing.tsx @@ -0,0 +1,42 @@ +import { Stack, Typography } from '@mui/material'; +import { ErrorOutline, CheckCircleOutline } from '@mui/icons-material'; +import type { ToolStep } from '../api'; +import { formatDuration } from '../lib/toolStepFormatting'; + +export default function ToolStepTrailing({ step }: { step: ToolStep }) { + const out = step.output; + if (!out) { + return ( + + pending + + ); + } + + const duration = formatDuration(out.durationMs); + const errored = out.isError || (typeof out.exitCode === 'number' && out.exitCode !== 0); + + return ( + + {errored ? ( + + ) : ( + + )} + {duration && ( + + {duration} + + )} + {typeof out.exitCode === 'number' && out.exitCode !== 0 && ( + + exit {out.exitCode} + + )} + + ); +} diff --git a/client/src/entities/message/ui/ToolStepsBlock.tsx b/client/src/entities/message/ui/ToolStepsBlock.tsx new file mode 100644 index 0000000..b19bb77 --- /dev/null +++ b/client/src/entities/message/ui/ToolStepsBlock.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { Box, Collapse, Stack, Typography } from '@mui/material'; +import { ExpandMore, Build, ErrorOutline, CheckCircleOutline } from '@mui/icons-material'; +import type { ToolStep } from '../api'; +import { aggregateStatus } from '../lib/toolStepFormatting'; +import ToolStepPair from './ToolStepPair'; + +interface ToolStepsBlockProps { + steps: ToolStep[]; + asStandalone?: boolean; +} + +export default function ToolStepsBlock({ steps, asStandalone }: ToolStepsBlockProps) { + const [open, setOpen] = useState(false); + if (!steps.length) return null; + + const status = aggregateStatus(steps); + const trailing = ( + + {status === 'ok' && ( + + )} + {status === 'error' && } + + {steps.length === 1 ? '1 call' : `${steps.length} calls`} + + + ); + + return ( + + setOpen((v) => !v)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.5, + cursor: 'pointer', + opacity: 0.75, + '&:hover': { opacity: 1 }, + minWidth: 0, + }} + > + + {asStandalone && } + + Tool execution + + + {trailing} + + + + {steps.map((step, idx) => ( + + ))} + + + + ); +} diff --git a/client/src/widgets/chat/model/types.ts b/client/src/widgets/chat/model/types.ts index 8fe340c..db6b7b8 100644 --- a/client/src/widgets/chat/model/types.ts +++ b/client/src/widgets/chat/model/types.ts @@ -1,5 +1,5 @@ import type { RefObject } from 'react'; -import type { Message, MessageFile } from '../../../entities/message'; +import type { Message, MessageFile, SessionRunStatus } from '../../../entities/message'; export interface ChatState { messages: Message[]; @@ -15,6 +15,10 @@ export interface ChatState { pendingUserText: string; pendingFilesPreviews: MessageFile[]; + /** Last-run state from the gateway daemon. `null` while unknown. */ + runStatus: SessionRunStatus | null; + /** Hide the timeout/abort banner for the current chat session. */ + send: (text: string, files: File[]) => Promise; loadMore: () => void; handleScroll: () => void; diff --git a/client/src/widgets/chat/model/useChat.ts b/client/src/widgets/chat/model/useChat.ts index c812db2..49f5f26 100644 --- a/client/src/widgets/chat/model/useChat.ts +++ b/client/src/widgets/chat/model/useChat.ts @@ -6,6 +6,7 @@ import { usePollMessagesQuery, type Message, type MessagesResponse, + type SessionRunStatus, } from '../../../entities/message'; import { useSendMessage } from '../../../features/message/send'; import type { ChatState } from './types'; @@ -146,6 +147,15 @@ export function useChat(conversationId: string | undefined): ChatState { if (loadMoreCursor !== undefined) setLoadMoreCursor(undefined); } + /* Run state is derived directly from the latest poll. Polling is paused + * while streaming, so by definition this only updates between runs. + * The MessageList consumes it to flag the last "stuck" message inline + * (exclamation icon + tooltip) — no banner state, no dismissal needed, + * because the indicator clears naturally when a new assistant reply + * arrives after the affected message. */ + const runStatus: SessionRunStatus | null = + pollData?.runStatus?.aborted ? pollData.runStatus : null; + useEffect(() => { return () => { abort(); @@ -182,6 +192,7 @@ export function useChat(conversationId: string | undefined): ChatState { streamError, pendingUserText, pendingFilesPreviews, + runStatus, send, loadMore, handleScroll, diff --git a/client/src/widgets/chat/ui/ChatInput.tsx b/client/src/widgets/chat/ui/ChatInput.tsx index 1dd4630..49d1852 100644 --- a/client/src/widgets/chat/ui/ChatInput.tsx +++ b/client/src/widgets/chat/ui/ChatInput.tsx @@ -7,6 +7,7 @@ import { InsertDriveFileOutlined, ImageOutlined, } from '@mui/icons-material'; +import { GatewayStatusDot } from '../../../entities/gateway'; interface ChatInputProps { onSend: (text: string, files: File[]) => Promise; @@ -96,6 +97,13 @@ export default function ChatInput({ onSend, isStreaming }: ChatInputProps) { }} > + {/* Gateway status dot — mirrors WebSocket health so the user sees + * immediately whether agent traffic can flow. Sits to the left + * of the paperclip so it stays visible without crowding the + * send-side controls. */} + + + fileInputRef.current?.click()} disabled={isStreaming || pendingFiles.length >= 5} diff --git a/client/src/widgets/chat/ui/MessageList.tsx b/client/src/widgets/chat/ui/MessageList.tsx index b6baaf8..f53332e 100644 --- a/client/src/widgets/chat/ui/MessageList.tsx +++ b/client/src/widgets/chat/ui/MessageList.tsx @@ -1,7 +1,23 @@ -import { Alert, Box, Typography, CircularProgress } from '@mui/material'; +import { Box, Typography, CircularProgress } from '@mui/material'; import { MessageBubble } from '../../../entities/message'; import type { ChatState } from '../model/types'; +/** Short, single-line copy shown inside the per-bubble error tooltip. */ +function describeDeliveryError(status: string | null, reason: string | null): string { + if (status === 'timeout') { + return 'The model went idle past its timeout — no reply was committed. Try resending.'; + } + if (status === 'error') { + return reason ? `Run failed: ${reason}.` : 'The daemon ended the run with an error.'; + } + if (status === 'cancelled') { + return 'The run was cancelled before a reply was committed.'; + } + return reason + ? `The daemon aborted the run: ${reason}.` + : 'The daemon aborted the run before a reply was committed.'; +} + interface MessageListProps { chat: ChatState; } @@ -18,14 +34,39 @@ export default function MessageList({ chat }: MessageListProps) { streamError, pendingUserText, pendingFilesPreviews, + runStatus, loadMore, loadMoreCursor, scrollContainerRef, messagesEndRef, handleScroll, - clearError, } = chat; + /* Two error sources can flag a stuck send: + * 1. `streamError` — the SSE pipeline itself failed (network drop, + * gateway 5xx, model timeout surfaced as an error event). Most + * immediate; clears automatically when the next send starts. + * 2. `runStatus.aborted` — the daemon's run ended abnormally on the + * previous turn (idle timeout, error, cancelled). Surfaces on the + * next poll between runs. + * Both render as the same inline marker (warning icon + tooltip) on + * whichever bubble is currently waiting for a reply. The marker clears + * naturally once an assistant reply arrives after that bubble. */ + const errorTooltip = streamError + ? streamError + : runStatus + ? describeDeliveryError(runStatus.status, runStatus.reason) + : null; + const lastInputIdx = + errorTooltip && !isStreaming + ? (() => { + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (messages[i].role !== 'assistant') return i; + } + return -1; + })() + : -1; + return ( - {streamError && ( - - - {streamError} - - - )} {isLoading && !loadMoreCursor ? ( @@ -90,8 +108,13 @@ export default function MessageList({ chat }: MessageListProps) { )} )} - {messages.map((msg) => ( - + {messages.map((msg, idx) => ( + ))} {isStreaming && (pendingUserText || pendingFilesPreviews.length > 0) && (