From 0fa05dc205ffc988972ed4e72712124defbe38b8 Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 17 Dec 2025 07:57:37 +0800 Subject: [PATCH] feat: add permission decision options, tool allowlisting, and refactored chat UI components --- server/src/sync/syncEngine.ts | 28 +- server/src/web/routes/permissions.ts | 24 +- web/src/api/client.ts | 25 +- web/src/chat/normalize.ts | 433 +++++++++++++++ web/src/chat/reducer.ts | 512 ++++++++++++++++++ web/src/chat/tracer.ts | 127 +++++ web/src/chat/types.ts | 142 +++++ web/src/components/ChatBlockList.tsx | 184 +++++++ web/src/components/MessageBubble.tsx | 130 ++++- web/src/components/SessionChat.tsx | 175 ++++-- .../components/ToolCard/PermissionFooter.tsx | 290 ++++++++++ web/src/components/ToolCard/ToolCard.tsx | 460 ++++++++++++++++ web/src/components/ToolCard/knownTools.ts | 219 ++++++++ web/src/components/ui/badge.tsx | 4 +- web/src/components/ui/dialog.tsx | 3 +- web/src/index.css | 6 + web/src/types/api.ts | 13 + 17 files changed, 2689 insertions(+), 86 deletions(-) create mode 100644 web/src/chat/normalize.ts create mode 100644 web/src/chat/reducer.ts create mode 100644 web/src/chat/tracer.ts create mode 100644 web/src/chat/types.ts create mode 100644 web/src/components/ChatBlockList.tsx create mode 100644 web/src/components/ToolCard/PermissionFooter.tsx create mode 100644 web/src/components/ToolCard/ToolCard.tsx create mode 100644 web/src/components/ToolCard/knownTools.ts diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 0992b5ce..24ff080f 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -37,13 +37,18 @@ export const AgentStateSchema = z.object({ tool: z.string(), arguments: z.unknown(), createdAt: z.number().nullish() - })).nullish(), + }).passthrough()).nullish(), completedRequests: z.record(z.string(), z.object({ tool: z.string(), arguments: z.unknown(), + createdAt: z.number().nullish(), + completedAt: z.number().nullish(), status: z.enum(['canceled', 'denied', 'approved']), - mode: z.string().nullish() - })).nullish() + reason: z.string().optional(), + mode: z.string().optional(), + decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(), + allowTools: z.array(z.string()).optional() + }).passthrough()).nullish() }).passthrough() export type AgentState = z.infer @@ -558,19 +563,28 @@ export class SyncEngine { async approvePermission( sessionId: string, requestId: string, - mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' + mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan', + allowTools?: string[], + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' ): Promise { await this.sessionRpc(sessionId, 'permission', { id: requestId, approved: true, - mode + mode, + allowTools, + decision }) } - async denyPermission(sessionId: string, requestId: string): Promise { + async denyPermission( + sessionId: string, + requestId: string, + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + ): Promise { await this.sessionRpc(sessionId, 'permission', { id: requestId, - approved: false + approved: false, + decision }) } diff --git a/server/src/web/routes/permissions.ts b/server/src/web/routes/permissions.ts index fc3cac2c..7c045460 100644 --- a/server/src/web/routes/permissions.ts +++ b/server/src/web/routes/permissions.ts @@ -4,8 +4,16 @@ import type { SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { requireSessionFromParam, requireSyncEngine } from './guards' +const decisionSchema = z.enum(['approved', 'approved_for_session', 'denied', 'abort']) + const approveBodySchema = z.object({ - mode: z.enum(['default', 'acceptEdits', 'bypassPermissions']).optional() + mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional(), + allowTools: z.array(z.string()).optional(), + decision: decisionSchema.optional() +}) + +const denyBodySchema = z.object({ + decision: decisionSchema.optional() }) export function createPermissionsRoutes(getSyncEngine: () => SyncEngine | null): Hono { @@ -26,7 +34,7 @@ export function createPermissionsRoutes(getSyncEngine: () => SyncEngine | null): const { sessionId, session } = sessionResult const json = await c.req.json().catch(() => null) - const parsed = approveBodySchema.safeParse(json) + const parsed = approveBodySchema.safeParse(json ?? {}) if (!parsed.success) { return c.json({ error: 'Invalid body' }, 400) } @@ -37,7 +45,9 @@ export function createPermissionsRoutes(getSyncEngine: () => SyncEngine | null): } const mode = parsed.data.mode - await engine.approvePermission(sessionId, requestId, mode) + const allowTools = parsed.data.allowTools + const decision = parsed.data.decision + await engine.approvePermission(sessionId, requestId, mode, allowTools, decision) return c.json({ ok: true }) }) @@ -60,7 +70,13 @@ export function createPermissionsRoutes(getSyncEngine: () => SyncEngine | null): return c.json({ error: 'Request not found' }, 404) } - await engine.denyPermission(sessionId, requestId) + const json = await c.req.json().catch(() => null) + const parsed = denyBodySchema.safeParse(json ?? {}) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + await engine.denyPermission(sessionId, requestId, parsed.data.decision) return c.json({ ok: true }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 77ffc93d..844ddfe6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -99,17 +99,34 @@ export class ApiClient { }) } - async approvePermission(sessionId: string, requestId: string, mode?: 'default' | 'acceptEdits' | 'bypassPermissions'): Promise { + async approvePermission( + sessionId: string, + requestId: string, + modeOrOptions?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | { + mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' + allowTools?: string[] + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + } + ): Promise { + const body = typeof modeOrOptions === 'string' || modeOrOptions === undefined + ? { mode: modeOrOptions } + : modeOrOptions await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(requestId)}/approve`, { method: 'POST', - body: JSON.stringify({ mode }) + body: JSON.stringify(body) }) } - async denyPermission(sessionId: string, requestId: string): Promise { + async denyPermission( + sessionId: string, + requestId: string, + options?: { + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + } + ): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(requestId)}/deny`, { method: 'POST', - body: JSON.stringify({}) + body: JSON.stringify(options ?? {}) }) } diff --git a/web/src/chat/normalize.ts b/web/src/chat/normalize.ts new file mode 100644 index 00000000..1489ea27 --- /dev/null +++ b/web/src/chat/normalize.ts @@ -0,0 +1,433 @@ +import type { DecryptedMessage } from '@/types/api' +import type { AgentEvent, NormalizedAgentContent, NormalizedMessage, ToolResultPermission } from '@/chat/types' + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +function asString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function safeStringify(value: unknown): string { + if (typeof value === 'string') return value + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +function isSkippableAgentContent(content: unknown): boolean { + if (!isObject(content) || content.type !== 'output') return false + const data = isObject(content.data) ? content.data : null + if (!data) return false + return Boolean(data.isMeta) || Boolean(data.isCompactSummary) +} + +type RoleWrappedRecord = { + role: string + content: unknown + meta?: unknown +} + +function isRoleWrappedRecord(value: unknown): value is RoleWrappedRecord { + if (!isObject(value)) return false + return typeof value.role === 'string' && 'content' in value +} + +function unwrapRoleWrappedRecordEnvelope(value: unknown): RoleWrappedRecord | null { + if (isRoleWrappedRecord(value)) return value + if (!isObject(value)) return null + + const direct = value.message + if (isRoleWrappedRecord(direct)) return direct + + const data = value.data + if (isObject(data) && isRoleWrappedRecord(data.message)) return data.message as RoleWrappedRecord + + const payload = value.payload + if (isObject(payload) && isRoleWrappedRecord(payload.message)) return payload.message as RoleWrappedRecord + + return null +} + +function normalizeToolResultPermissions(value: unknown): ToolResultPermission | undefined { + if (!isObject(value)) return undefined + const date = asNumber(value.date) + const result = value.result + if (date === null) return undefined + if (result !== 'approved' && result !== 'denied') return undefined + + const mode = asString(value.mode) ?? undefined + const allowedTools = Array.isArray(value.allowedTools) + ? value.allowedTools.filter((tool) => typeof tool === 'string') + : undefined + const decision = value.decision + const normalizedDecision = decision === 'approved' || decision === 'approved_for_session' || decision === 'denied' || decision === 'abort' + ? decision + : undefined + + return { + date, + result, + mode, + allowedTools, + decision: normalizedDecision + } +} + +function normalizeAgentEvent(value: unknown): AgentEvent | null { + if (!isObject(value) || typeof value.type !== 'string') return null + return value as AgentEvent +} + +function normalizeAssistantOutput( + messageId: string, + localId: string | null, + createdAt: number, + data: Record, + meta?: unknown +): NormalizedMessage | null { + const uuid = asString(data.uuid) ?? messageId + const parentUUID = asString(data.parentUuid) ?? null + const isSidechain = Boolean(data.isSidechain) + + const message = isObject(data.message) ? data.message : null + if (!message) return null + + const modelContent = message.content + const blocks: NormalizedAgentContent[] = [] + + if (typeof modelContent === 'string') { + blocks.push({ type: 'text', text: modelContent, uuid, parentUUID }) + } else if (Array.isArray(modelContent)) { + for (const block of modelContent) { + if (!isObject(block) || typeof block.type !== 'string') continue + if (block.type === 'text' && typeof block.text === 'string') { + blocks.push({ type: 'text', text: block.text, uuid, parentUUID }) + continue + } + if (block.type === 'tool_use' && typeof block.id === 'string') { + const name = asString(block.name) ?? 'Tool' + const input = 'input' in block ? (block as Record).input : undefined + const description = isObject(input) && typeof input.description === 'string' ? input.description : null + blocks.push({ type: 'tool-call', id: block.id, name, input, description, uuid, parentUUID }) + } + } + } + + const usage = isObject(message.usage) ? (message.usage as Record) : null + const inputTokens = usage ? asNumber(usage.input_tokens) : null + const outputTokens = usage ? asNumber(usage.output_tokens) : null + + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain, + content: blocks, + meta, + usage: inputTokens !== null && outputTokens !== null ? { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: asNumber(usage?.cache_creation_input_tokens) ?? undefined, + cache_read_input_tokens: asNumber(usage?.cache_read_input_tokens) ?? undefined, + service_tier: asString(usage?.service_tier) ?? undefined + } : undefined + } +} + +function normalizeUserOutput( + messageId: string, + localId: string | null, + createdAt: number, + data: Record, + meta?: unknown +): NormalizedMessage | null { + const uuid = asString(data.uuid) ?? messageId + const parentUUID = asString(data.parentUuid) ?? null + const isSidechain = Boolean(data.isSidechain) + + const message = isObject(data.message) ? data.message : null + if (!message) return null + + const messageContent = message.content + + if (isSidechain && typeof messageContent === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: true, + content: [{ type: 'sidechain', uuid, prompt: messageContent }] + } + } + + if (typeof messageContent === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'user', + isSidechain: false, + content: { type: 'text', text: messageContent }, + meta + } + } + + const blocks: NormalizedAgentContent[] = [] + + if (Array.isArray(messageContent)) { + for (const block of messageContent) { + if (!isObject(block) || typeof block.type !== 'string') continue + if (block.type === 'text' && typeof block.text === 'string') { + blocks.push({ type: 'text', text: block.text, uuid, parentUUID }) + continue + } + if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') { + const isError = Boolean(block.is_error) + const rawContent = 'content' in block ? (block as Record).content : undefined + const embeddedToolUseResult = 'toolUseResult' in data ? (data as Record).toolUseResult : null + + const permissions = normalizeToolResultPermissions(block.permissions) + + blocks.push({ + type: 'tool-result', + tool_use_id: block.tool_use_id, + content: embeddedToolUseResult ?? rawContent, + is_error: isError, + uuid, + parentUUID, + permissions + }) + } + } + } + + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain, + content: blocks, + meta + } +} + +function normalizeAgentRecord( + messageId: string, + localId: string | null, + createdAt: number, + content: unknown, + meta?: unknown +): NormalizedMessage | null { + if (!isObject(content) || typeof content.type !== 'string') return null + + if (content.type === 'output') { + const data = isObject(content.data) ? content.data : null + if (!data || typeof data.type !== 'string') return null + + // Skip meta/compact-summary messages (parity with happy-app) + if (data.isMeta) return null + if (data.isCompactSummary) return null + + if (data.type === 'assistant') { + return normalizeAssistantOutput(messageId, localId, createdAt, data, meta) + } + if (data.type === 'user') { + return normalizeUserOutput(messageId, localId, createdAt, data, meta) + } + if (data.type === 'summary' && typeof data.summary === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ type: 'summary', summary: data.summary }], + meta + } + } + return null + } + + if (content.type === 'event') { + const event = normalizeAgentEvent(content.data) + if (!event) return null + return { + id: messageId, + localId, + createdAt, + role: 'event', + content: event, + isSidechain: false, + meta + } + } + + if (content.type === 'codex') { + const data = isObject(content.data) ? content.data : null + if (!data || typeof data.type !== 'string') return null + + if ((data.type === 'message' || data.type === 'reasoning') && typeof data.message === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ type: 'text', text: data.message, uuid: messageId, parentUUID: null }], + meta + } + } + + if (data.type === 'tool-call' && typeof data.callId === 'string') { + const uuid = asString(data.id) ?? messageId + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: data.callId, + name: asString(data.name) ?? 'unknown', + input: data.input, + description: null, + uuid, + parentUUID: null + }], + meta + } + } + + if (data.type === 'tool-call-result' && typeof data.callId === 'string') { + const uuid = asString(data.id) ?? messageId + return { + id: messageId, + localId, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: data.callId, + content: data.output, + is_error: false, + uuid, + parentUUID: null + }], + meta + } + } + } + + return null +} + +function normalizeUserRecord( + messageId: string, + localId: string | null, + createdAt: number, + content: unknown, + meta?: unknown +): NormalizedMessage | null { + if (typeof content === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'user', + content: { type: 'text', text: content }, + isSidechain: false, + meta + } + } + + if (isObject(content) && content.type === 'text' && typeof content.text === 'string') { + return { + id: messageId, + localId, + createdAt, + role: 'user', + content: { type: 'text', text: content.text }, + isSidechain: false, + meta + } + } + + return null +} + +export function normalizeDecryptedMessage(message: DecryptedMessage): NormalizedMessage | null { + const record = unwrapRoleWrappedRecordEnvelope(message.content) + if (!record) { + return { + id: message.id, + localId: message.localId, + createdAt: message.createdAt, + role: 'agent', + isSidechain: false, + content: [{ type: 'text', text: safeStringify(message.content), uuid: message.id, parentUUID: null }], + status: message.status, + originalText: message.originalText + } + } + + if (record.role === 'user') { + const normalized = normalizeUserRecord(message.id, message.localId, message.createdAt, record.content, record.meta) + return normalized + ? { ...normalized, status: message.status, originalText: message.originalText } + : { + id: message.id, + localId: message.localId, + createdAt: message.createdAt, + role: 'user', + isSidechain: false, + content: { type: 'text', text: safeStringify(record.content) }, + meta: record.meta, + status: message.status, + originalText: message.originalText + } + } + if (record.role === 'agent') { + if (isSkippableAgentContent(record.content)) { + return null + } + const normalized = normalizeAgentRecord(message.id, message.localId, message.createdAt, record.content, record.meta) + return normalized + ? { ...normalized, status: message.status, originalText: message.originalText } + : { + id: message.id, + localId: message.localId, + createdAt: message.createdAt, + role: 'agent', + isSidechain: false, + content: [{ type: 'text', text: safeStringify(record.content), uuid: message.id, parentUUID: null }], + meta: record.meta, + status: message.status, + originalText: message.originalText + } + } + + return { + id: message.id, + localId: message.localId, + createdAt: message.createdAt, + role: 'agent', + isSidechain: false, + content: [{ type: 'text', text: safeStringify(record.content), uuid: message.id, parentUUID: null }], + meta: record.meta, + status: message.status, + originalText: message.originalText + } +} diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts new file mode 100644 index 00000000..ef5ae6c5 --- /dev/null +++ b/web/src/chat/reducer.ts @@ -0,0 +1,512 @@ +import type { AgentState } from '@/types/api' +import type { AgentEvent, ChatBlock, ChatToolCall, NormalizedMessage, ToolCallBlock, ToolPermission } from '@/chat/types' +import { traceMessages, type TracedMessage } from '@/chat/tracer' + +function parseClaudeUsageLimit(text: string): number | null { + const match = text.match(/^Claude AI usage limit reached\|(\d+)$/) + if (!match) return null + const timestamp = Number.parseInt(match[1], 10) + if (!Number.isFinite(timestamp)) return null + return timestamp +} + +function parseMessageAsEvent(msg: NormalizedMessage): AgentEvent | null { + if (msg.isSidechain) return null + if (msg.role !== 'agent') return null + + for (const content of msg.content) { + if (content.type === 'text') { + const limitReached = parseClaudeUsageLimit(content.text) + if (limitReached !== null) { + return { type: 'limit-reached', endsAt: limitReached } + } + } + } + + return null +} + +function extractTitleFromChangeTitleInput(input: unknown): string | null { + if (!input || typeof input !== 'object') return null + const title = (input as { title?: unknown }).title + return typeof title === 'string' && title.trim().length > 0 ? title.trim() : null +} + +function collectTitleChanges(messages: NormalizedMessage[]): Map { + const map = new Map() + for (const msg of messages) { + if (msg.role !== 'agent') continue + for (const content of msg.content) { + if (content.type !== 'tool-call') continue + if (content.name !== 'mcp__happy__change_title') continue + const title = extractTitleFromChangeTitleInput(content.input) + if (!title) continue + map.set(content.id, title) + } + } + return map +} + +function dedupeAgentEvents(blocks: ChatBlock[]): ChatBlock[] { + const result: ChatBlock[] = [] + let prevEventKey: string | null = null + let prevTitleChangedTo: string | null = null + + for (const block of blocks) { + if (block.kind !== 'agent-event') { + result.push(block) + prevEventKey = null + prevTitleChangedTo = null + continue + } + + const event = block.event as { type: string; [key: string]: unknown } + if (event.type === 'title-changed' && typeof event.title === 'string') { + const title = event.title.trim() + const key = `title-changed:${title}` + if (key === prevEventKey) { + continue + } + result.push(block) + prevEventKey = key + prevTitleChangedTo = title + continue + } + + if (event.type === 'message' && typeof event.message === 'string') { + const message = event.message.trim() + const key = `message:${message}` + if (key === prevEventKey) { + continue + } + if (prevTitleChangedTo && message === prevTitleChangedTo) { + continue + } + result.push(block) + prevEventKey = key + prevTitleChangedTo = null + continue + } + + let key: string + try { + key = `event:${JSON.stringify(event)}` + } catch { + key = `event:${String(event.type)}` + } + + if (key === prevEventKey) { + continue + } + + result.push(block) + prevEventKey = key + prevTitleChangedTo = null + } + + return result +} + +type PermissionEntry = { + toolName: string + input: unknown + permission: ToolPermission +} + +function getPermissions(agentState: AgentState | null | undefined): Map { + const map = new Map() + + const completed = agentState?.completedRequests ?? null + if (completed) { + for (const [id, entry] of Object.entries(completed)) { + map.set(id, { + toolName: entry.tool, + input: entry.arguments, + permission: { + id, + status: entry.status, + reason: entry.reason ?? undefined, + mode: entry.mode ?? undefined, + decision: entry.decision ?? undefined, + allowedTools: entry.allowTools, + createdAt: entry.createdAt ?? null, + completedAt: entry.completedAt ?? null + } + }) + } + } + + const requests = agentState?.requests ?? null + if (requests) { + for (const [id, request] of Object.entries(requests)) { + if (map.has(id)) continue + map.set(id, { + toolName: request.tool, + input: request.arguments, + permission: { + id, + status: 'pending', + createdAt: request.createdAt ?? null + } + }) + } + } + + return map +} + +function ensureToolBlock( + blocks: ChatBlock[], + toolBlocksById: Map, + id: string, + seed: { + createdAt: number + localId: string | null + meta?: unknown + name: string + input: unknown + description: string | null + permission?: ToolPermission + } +): ToolCallBlock { + const existing = toolBlocksById.get(id) + if (existing) { + // Preserve earliest createdAt for stable ordering. + if (seed.createdAt < existing.createdAt) { + existing.createdAt = seed.createdAt + existing.tool.createdAt = seed.createdAt + } + if (seed.permission) { + existing.tool.permission = { ...existing.tool.permission, ...seed.permission } + if (existing.tool.state === 'running' && seed.permission.status === 'pending') { + existing.tool.state = 'pending' + } + } + if (seed.name) { + existing.tool.name = seed.name + } + existing.tool.input = seed.input + existing.tool.description = seed.description + return existing + } + + const initialState: ChatToolCall['state'] = seed.permission?.status === 'pending' + ? 'pending' + : seed.permission?.status === 'denied' || seed.permission?.status === 'canceled' + ? 'error' + : 'running' + + const tool: ChatToolCall = { + id, + name: seed.name, + state: initialState, + input: seed.input, + createdAt: seed.createdAt, + startedAt: initialState === 'running' ? seed.createdAt : null, + completedAt: null, + description: seed.description, + permission: seed.permission + } + + const block: ToolCallBlock = { + kind: 'tool-call', + id, + localId: seed.localId, + createdAt: seed.createdAt, + tool, + children: [], + meta: seed.meta + } + + toolBlocksById.set(id, block) + blocks.push(block) + return block +} + +function collectToolIdsFromMessages(messages: NormalizedMessage[]): Set { + const ids = new Set() + for (const msg of messages) { + if (msg.role !== 'agent') continue + for (const content of msg.content) { + if (content.type === 'tool-call') { + ids.add(content.id) + } else if (content.type === 'tool-result') { + ids.add(content.tool_use_id) + } + } + } + return ids +} + +function reduceTimeline( + messages: TracedMessage[], + context: { + permissionsById: Map + groups: Map + consumedGroupIds: Set + titleChangesByToolUseId: Map + emittedTitleChangeToolUseIds: Set + } +): { blocks: ChatBlock[]; toolBlocksById: Map; hasReadyEvent: boolean } { + const blocks: ChatBlock[] = [] + const toolBlocksById = new Map() + let hasReadyEvent = false + + for (const msg of messages) { + if (msg.role === 'event') { + if (msg.content.type === 'ready') { + hasReadyEvent = true + continue + } + blocks.push({ + kind: 'agent-event', + id: msg.id, + createdAt: msg.createdAt, + event: msg.content, + meta: msg.meta + }) + continue + } + + const event = parseMessageAsEvent(msg) + if (event) { + blocks.push({ + kind: 'agent-event', + id: msg.id, + createdAt: msg.createdAt, + event, + meta: msg.meta + }) + continue + } + + if (msg.role === 'user') { + blocks.push({ + kind: 'user-text', + id: msg.id, + localId: msg.localId, + createdAt: msg.createdAt, + text: msg.content.text, + status: msg.status, + originalText: msg.originalText, + meta: msg.meta + }) + continue + } + + if (msg.role === 'agent') { + for (let idx = 0; idx < msg.content.length; idx += 1) { + const c = msg.content[idx] + if (c.type === 'text') { + blocks.push({ + kind: 'agent-text', + id: `${msg.id}:${idx}`, + localId: msg.localId, + createdAt: msg.createdAt, + text: c.text, + meta: msg.meta + }) + continue + } + + if (c.type === 'summary') { + blocks.push({ + kind: 'agent-event', + id: `${msg.id}:${idx}`, + createdAt: msg.createdAt, + event: { type: 'message', message: c.summary }, + meta: msg.meta + }) + continue + } + + if (c.type === 'tool-call') { + if (c.name === 'mcp__happy__change_title') { + const title = context.titleChangesByToolUseId.get(c.id) ?? extractTitleFromChangeTitleInput(c.input) + if (title && !context.emittedTitleChangeToolUseIds.has(c.id)) { + context.emittedTitleChangeToolUseIds.add(c.id) + blocks.push({ + kind: 'agent-event', + id: `${msg.id}:${idx}`, + createdAt: msg.createdAt, + event: { type: 'title-changed', title }, + meta: msg.meta + }) + } + continue + } + + const permission = context.permissionsById.get(c.id)?.permission + + const block = ensureToolBlock(blocks, toolBlocksById, c.id, { + createdAt: msg.createdAt, + localId: msg.localId, + meta: msg.meta, + name: c.name, + input: c.input, + description: c.description, + permission + }) + + if (block.tool.state === 'pending') { + block.tool.state = 'running' + block.tool.startedAt = msg.createdAt + } + + if (c.name === 'Task' && !context.consumedGroupIds.has(msg.id)) { + const sidechain = context.groups.get(msg.id) ?? null + if (sidechain && sidechain.length > 0) { + context.consumedGroupIds.add(msg.id) + const child = reduceTimeline(sidechain, context) + hasReadyEvent = hasReadyEvent || child.hasReadyEvent + block.children = child.blocks + } + } + continue + } + + if (c.type === 'tool-result') { + const title = context.titleChangesByToolUseId.get(c.tool_use_id) ?? null + if (title) { + if (!context.emittedTitleChangeToolUseIds.has(c.tool_use_id)) { + context.emittedTitleChangeToolUseIds.add(c.tool_use_id) + blocks.push({ + kind: 'agent-event', + id: `${msg.id}:${idx}`, + createdAt: msg.createdAt, + event: { type: 'title-changed', title }, + meta: msg.meta + }) + } + continue + } + + const permissionEntry = context.permissionsById.get(c.tool_use_id) + const permissionFromResult = c.permissions ? ({ + id: c.tool_use_id, + status: c.permissions.result === 'approved' ? 'approved' : 'denied', + date: c.permissions.date, + mode: c.permissions.mode, + allowedTools: c.permissions.allowedTools, + decision: c.permissions.decision + } satisfies ToolPermission) : undefined + + const permission = (() => { + if (permissionFromResult && permissionEntry?.permission) { + return { + ...permissionEntry.permission, + ...permissionFromResult, + allowedTools: permissionFromResult.allowedTools ?? permissionEntry.permission.allowedTools, + decision: permissionFromResult.decision ?? permissionEntry.permission.decision + } satisfies ToolPermission + } + return permissionFromResult ?? permissionEntry?.permission + })() + + const block = ensureToolBlock(blocks, toolBlocksById, c.tool_use_id, { + createdAt: msg.createdAt, + localId: msg.localId, + meta: msg.meta, + name: permissionEntry?.toolName ?? 'Tool', + input: permissionEntry?.input ?? null, + description: null, + permission + }) + + block.tool.result = c.content + block.tool.completedAt = msg.createdAt + block.tool.state = c.is_error ? 'error' : 'completed' + continue + } + + if (c.type === 'sidechain') { + blocks.push({ + kind: 'user-text', + id: `${msg.id}:${idx}`, + localId: null, + createdAt: msg.createdAt, + text: c.prompt + }) + } + } + } + } + + return { blocks, toolBlocksById, hasReadyEvent } +} + +export function reduceChatBlocks( + normalized: NormalizedMessage[], + agentState: AgentState | null | undefined +): { blocks: ChatBlock[]; hasReadyEvent: boolean } { + const permissionsById = getPermissions(agentState) + const toolIdsInMessages = collectToolIdsFromMessages(normalized) + const titleChangesByToolUseId = collectTitleChanges(normalized) + + const traced = traceMessages(normalized) + const groups = new Map() + const root: TracedMessage[] = [] + + for (const msg of traced) { + if (msg.sidechainId) { + const existing = groups.get(msg.sidechainId) ?? [] + existing.push(msg) + groups.set(msg.sidechainId, existing) + } else { + root.push(msg) + } + } + + const consumedGroupIds = new Set() + const emittedTitleChangeToolUseIds = new Set() + const reducerContext = { permissionsById, groups, consumedGroupIds, titleChangesByToolUseId, emittedTitleChangeToolUseIds } + const rootResult = reduceTimeline(root, reducerContext) + let hasReadyEvent = rootResult.hasReadyEvent + + // If a group couldn't be attached to a Task tool call (e.g. legacy shapes), keep it visible. + for (const [taskMessageId, sidechainMessages] of groups) { + if (consumedGroupIds.has(taskMessageId)) continue + if (sidechainMessages.length === 0) continue + const child = reduceTimeline(sidechainMessages, reducerContext) + hasReadyEvent = hasReadyEvent || child.hasReadyEvent + rootResult.blocks.push({ + kind: 'agent-event', + id: `sidechain:${taskMessageId}`, + createdAt: sidechainMessages[0].createdAt, + event: { type: 'message', message: 'Task sidechain' } + }) + rootResult.blocks.push(...child.blocks) + } + + // Only create permission-only tool cards when there is no tool call/result in the transcript. + for (const [id, entry] of permissionsById) { + if (toolIdsInMessages.has(id)) continue + if (rootResult.toolBlocksById.has(id)) continue + + const createdAt = entry.permission.createdAt ?? Date.now() + const block = ensureToolBlock(rootResult.blocks, rootResult.toolBlocksById, id, { + createdAt, + localId: null, + name: entry.toolName, + input: entry.input, + description: null, + permission: entry.permission + }) + + if (entry.permission.status === 'approved') { + block.tool.state = 'completed' + block.tool.completedAt = entry.permission.completedAt ?? createdAt + if (block.tool.result === undefined) { + block.tool.result = 'Approved' + } + } else if (entry.permission.status === 'denied' || entry.permission.status === 'canceled') { + block.tool.state = 'error' + block.tool.completedAt = entry.permission.completedAt ?? createdAt + if (block.tool.result === undefined && entry.permission.reason) { + block.tool.result = { error: entry.permission.reason } + } + } + } + + return { blocks: dedupeAgentEvents(rootResult.blocks), hasReadyEvent } +} diff --git a/web/src/chat/tracer.ts b/web/src/chat/tracer.ts new file mode 100644 index 00000000..411fbb41 --- /dev/null +++ b/web/src/chat/tracer.ts @@ -0,0 +1,127 @@ +import type { NormalizedMessage } from '@/chat/types' + +export type TracedMessage = NormalizedMessage & { + sidechainId?: string +} + +type TracerState = { + promptToTaskId: Map + uuidToSidechainId: Map + orphanMessages: Map +} + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +function getMessageUuid(message: NormalizedMessage): string | null { + if (message.role === 'agent' && message.content.length > 0) { + const first = message.content[0] as unknown as Record + return typeof first.uuid === 'string' ? first.uuid : null + } + return null +} + +function getParentUuid(message: NormalizedMessage): string | null { + if (message.role === 'agent' && message.content.length > 0) { + const first = message.content[0] as unknown as Record + return typeof first.parentUUID === 'string' ? first.parentUUID : null + } + return null +} + +function processOrphans(state: TracerState, parentUuid: string, sidechainId: string): TracedMessage[] { + const results: TracedMessage[] = [] + const orphans = state.orphanMessages.get(parentUuid) + if (!orphans) return results + state.orphanMessages.delete(parentUuid) + + for (const orphan of orphans) { + const uuid = getMessageUuid(orphan) + if (uuid) { + state.uuidToSidechainId.set(uuid, sidechainId) + } + + results.push({ ...orphan, sidechainId }) + + if (uuid) { + results.push(...processOrphans(state, uuid, sidechainId)) + } + } + + return results +} + +export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] { + const state: TracerState = { + promptToTaskId: new Map(), + uuidToSidechainId: new Map(), + orphanMessages: new Map() + } + + const results: TracedMessage[] = [] + + // Index Task prompts (including those inside sidechains). + for (const message of messages) { + if (message.role !== 'agent') continue + for (const content of message.content) { + if (content.type !== 'tool-call' || content.name !== 'Task') continue + const input = content.input + if (!isObject(input) || typeof input.prompt !== 'string') continue + state.promptToTaskId.set(input.prompt, message.id) + } + } + + for (const message of messages) { + if (!message.isSidechain) { + results.push({ ...message }) + continue + } + + const uuid = getMessageUuid(message) + const parentUuid = getParentUuid(message) + + // Sidechain root matching (prompt == Task.prompt). + let sidechainId: string | undefined + if (message.role === 'agent') { + for (const content of message.content) { + if (content.type !== 'sidechain') continue + const taskId = state.promptToTaskId.get(content.prompt) + if (taskId) { + sidechainId = taskId + break + } + } + } + + if (sidechainId && uuid) { + state.uuidToSidechainId.set(uuid, sidechainId) + results.push({ ...message, sidechainId }) + results.push(...processOrphans(state, uuid, sidechainId)) + continue + } + + if (parentUuid) { + const parentSidechainId = state.uuidToSidechainId.get(parentUuid) + if (parentSidechainId) { + if (uuid) { + state.uuidToSidechainId.set(uuid, parentSidechainId) + } + results.push({ ...message, sidechainId: parentSidechainId }) + if (uuid) { + results.push(...processOrphans(state, uuid, parentSidechainId)) + } + } else { + const orphans = state.orphanMessages.get(parentUuid) ?? [] + orphans.push(message) + state.orphanMessages.set(parentUuid, orphans) + } + continue + } + + results.push({ ...message }) + } + + return results +} + diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts new file mode 100644 index 00000000..42e92026 --- /dev/null +++ b/web/src/chat/types.ts @@ -0,0 +1,142 @@ +import type { MessageStatus } from '@/types/api' + +export type UsageData = { + input_tokens: number + output_tokens: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + service_tier?: string +} + +export type AgentEvent = + | { type: 'switch'; mode: 'local' | 'remote' } + | { type: 'message'; message: string } + | { type: 'title-changed'; title: string } + | { type: 'limit-reached'; endsAt: number } + | { type: 'ready' } + | ({ type: string } & Record) + +export type ToolResultPermission = { + date: number + result: 'approved' | 'denied' + mode?: string + allowedTools?: string[] + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' +} + +export type ToolUse = { + type: 'tool-call' + id: string + name: string + input: unknown + description: string | null + uuid: string + parentUUID: string | null +} + +export type ToolResult = { + type: 'tool-result' + tool_use_id: string + content: unknown + is_error: boolean + uuid: string + parentUUID: string | null + permissions?: ToolResultPermission +} + +export type NormalizedAgentContent = + | { + type: 'text' + text: string + uuid: string + parentUUID: string | null + } + | ToolUse + | ToolResult + | { type: 'summary'; summary: string } + | { type: 'sidechain'; uuid: string; prompt: string } + +export type NormalizedMessage = ({ + role: 'user' + content: { type: 'text'; text: string } +} | { + role: 'agent' + content: NormalizedAgentContent[] +} | { + role: 'event' + content: AgentEvent +}) & { + id: string + localId: string | null + createdAt: number + isSidechain: boolean + meta?: unknown + usage?: UsageData + status?: MessageStatus + originalText?: string +} + +export type ToolPermission = { + id: string + status: 'pending' | 'approved' | 'denied' | 'canceled' + reason?: string + mode?: string + allowedTools?: string[] + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + date?: number + createdAt?: number | null + completedAt?: number | null +} + +export type ChatToolCall = { + id: string + name: string + state: 'pending' | 'running' | 'completed' | 'error' + input: unknown + createdAt: number + startedAt: number | null + completedAt: number | null + description: string | null + result?: unknown + permission?: ToolPermission +} + +export type UserTextBlock = { + kind: 'user-text' + id: string + localId: string | null + createdAt: number + text: string + status?: MessageStatus + originalText?: string + meta?: unknown +} + +export type AgentTextBlock = { + kind: 'agent-text' + id: string + localId: string | null + createdAt: number + text: string + meta?: unknown +} + +export type AgentEventBlock = { + kind: 'agent-event' + id: string + createdAt: number + event: AgentEvent + meta?: unknown +} + +export type ToolCallBlock = { + kind: 'tool-call' + id: string + localId: string | null + createdAt: number + tool: ChatToolCall + children: ChatBlock[] + meta?: unknown +} + +export type ChatBlock = UserTextBlock | AgentTextBlock | ToolCallBlock | AgentEventBlock diff --git a/web/src/components/ChatBlockList.tsx b/web/src/components/ChatBlockList.tsx new file mode 100644 index 00000000..114b6e0c --- /dev/null +++ b/web/src/components/ChatBlockList.tsx @@ -0,0 +1,184 @@ +import type { ChatBlock } from '@/chat/types' +import type { MessageStatus } from '@/types/api' +import type { ApiClient } from '@/api/client' +import type { SessionMetadataSummary } from '@/types/api' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { ToolCard } from '@/components/ToolCard/ToolCard' + +function ErrorIcon() { + return ( + + + + + + ) +} + +function MessageStatusIndicator(props: { + status?: MessageStatus + onRetry?: () => void +}) { + if (props.status !== 'failed') { + return null + } + + return ( + + + + + {props.onRetry ? ( + + ) : null} + + ) +} + +function formatUnixTimestamp(value: number): string { + const ms = value < 1_000_000_000_000 ? value * 1000 : value + const date = new Date(ms) + if (Number.isNaN(date.getTime())) return String(value) + return date.toLocaleString() +} + +function renderEventLabel(event: ChatBlock & { kind: 'agent-event' }): string { + const data = event.event as { type: string; [key: string]: unknown } + if (data.type === 'switch') { + const mode = data.mode === 'local' ? 'local' : 'remote' + return `🔄 Switched to ${mode}` + } + if (data.type === 'title-changed') { + const title = typeof data.title === 'string' ? data.title : '' + return title ? `Title changed to "${title}"` : 'Title changed' + } + if (data.type === 'permission-mode-changed') { + const mode = typeof data.mode === 'string' ? data.mode : 'default' + return `🔐 Permission mode: ${mode}` + } + if (data.type === 'limit-reached') { + const endsAt = typeof data.endsAt === 'number' ? data.endsAt : null + return endsAt ? `⏳ Usage limit reached until ${formatUnixTimestamp(endsAt)}` : '⏳ Usage limit reached' + } + if (data.type === 'message') { + return typeof data.message === 'string' ? data.message : 'Message' + } + try { + return JSON.stringify(data) + } catch { + return 'Event' + } +} + +export function ChatBlockList(props: { + api: ApiClient + sessionId: string + metadata: SessionMetadataSummary | null + disabled: boolean + onRefresh: () => void + blocks: ChatBlock[] + onRetryMessage?: (localId: string) => void +}) { + return ( +
+ {props.blocks.map((block) => { + if (block.kind === 'user-text') { + const userBubbleClass = 'w-fit max-w-[92%] ml-auto rounded-xl border border-[var(--app-border)] bg-[var(--app-secondary-bg)] px-3 py-2 text-[var(--app-fg)] shadow-sm' + const status = block.status + const onRetry = block.localId && status === 'failed' && props.onRetryMessage + ? () => props.onRetryMessage!(block.localId!) + : undefined + + return ( +
+
+
+ +
+ {status ? ( +
+ +
+ ) : null} +
+
+ ) + } + + if (block.kind === 'agent-text') { + return ( +
+ +
+ ) + } + + if (block.kind === 'agent-event') { + return ( +
+
+ {renderEventLabel(block)} +
+
+ ) + } + + if (block.kind === 'tool-call') { + const isTask = block.tool.name === 'Task' + return ( +
+ + {block.children.length > 0 ? ( + isTask ? ( +
+ + Task details ({block.children.length}) + +
+ +
+
+ ) : ( +
+ +
+ ) + ) : null} +
+ ) + } + + return null + })} +
+ ) +} diff --git a/web/src/components/MessageBubble.tsx b/web/src/components/MessageBubble.tsx index c1b3dde3..4340d58b 100644 --- a/web/src/components/MessageBubble.tsx +++ b/web/src/components/MessageBubble.tsx @@ -13,6 +13,40 @@ function truncate(text: string, maxLen: number): string { return text.slice(0, maxLen - 3) + '...' } +/** + * Converts snake_case string to Title Case with spaces. + * Example: "create_issue" -> "Create Issue" + */ +function snakeToTitleWithSpaces(value: string): string { + return value + .split('_') + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(' ') +} + +/** + * Formats MCP tool names for display. + * Example: "mcp__linear__create_issue" -> "MCP: Linear Create Issue" + */ +function formatMCPTitle(toolName: string): string { + const withoutPrefix = toolName.replace(/^mcp__/, '') + const parts = withoutPrefix.split('__') + if (parts.length >= 2) { + const serverName = snakeToTitleWithSpaces(parts[0]) + const toolPart = snakeToTitleWithSpaces(parts.slice(1).join('_')) + return `MCP: ${serverName} ${toolPart}` + } + return `MCP: ${snakeToTitleWithSpaces(withoutPrefix)}` +} + +function formatToolTitle(toolName: string): string { + if (toolName.startsWith('mcp__')) { + return formatMCPTitle(toolName) + } + return toolName +} + type RoleWrappedMessage = { role: string content: unknown @@ -81,7 +115,7 @@ function renderRoleWrappedMessageContent(message: RoleWrappedMessage): ReactNode function formatEventLabel(event: unknown): string { if (!isObject(event)) return 'Event' const type = event.type - if (type === 'ready') return '✅ Ready for input' + if (type === 'ready') return 'ready' if (type === 'switch') { const mode = event.mode === 'local' ? 'local' : 'remote' return `🔄 Switched to ${mode}` @@ -101,6 +135,38 @@ function formatEventLabel(event: unknown): string { } } +function parseToolUseError(message: string): { isToolUseError: boolean; errorMessage: string | null } { + const regex = /(.*?)<\/tool_use_error>/s + const match = message.match(regex) + + if (match) { + return { + isToolUseError: true, + errorMessage: typeof match[1] === 'string' ? match[1].trim() : '' + } + } + + return { + isToolUseError: false, + errorMessage: null + } +} + +function parseClaudeUsageLimit(text: string): number | null { + const match = text.match(/^Claude AI usage limit reached\|(\d+)$/) + if (!match) return null + const timestamp = Number.parseInt(match[1], 10) + if (!Number.isFinite(timestamp)) return null + return timestamp +} + +function formatUnixTimestamp(value: number): string { + const ms = value < 1_000_000_000_000 ? value * 1000 : value + const date = new Date(ms) + if (Number.isNaN(date.getTime())) return String(value) + return date.toLocaleString() +} + function getToolName(value: Record): string { if (typeof value.name === 'string') return value.name if (typeof value.tool === 'string') return value.tool @@ -213,6 +279,8 @@ function ToolUseView(props: { toolName: string; input: unknown }) { const url = getInputStringAny(normalizedInput, ['url']) const prompt = getInputStringAny(normalizedInput, ['description', 'prompt']) + const title = formatToolTitle(props.toolName) + // Generate compact title suffix const titleSuffix = filePath ? `: ${filePath.split('/').pop() ?? filePath}` @@ -234,12 +302,12 @@ function ToolUseView(props: { toolName: string; input: unknown }) { type="button" className="flex items-center gap-1 text-left text-xs font-medium text-[var(--app-hint)] hover:underline" > - 🔧 {props.toolName}{titleSuffix} + 🔧 {title}{titleSuffix} - 🔧 {props.toolName} + 🔧 {title}
{filePath && ( @@ -282,8 +350,17 @@ function ToolUseView(props: { toolName: string; input: unknown }) { function ToolResultView(props: { isError: boolean; content: unknown }) { const text = extractTextFromToolResult(props.content) - const summary = text !== null ? generateOutputSummary(text) : null - const header = props.isError ? '❌ Tool error' : '✓ Tool result' + const toolUseError = text !== null ? parseToolUseError(text) : null + const toolUseErrorText = toolUseError?.isToolUseError ? (toolUseError.errorMessage ?? '') : null + + const displayText = toolUseError?.isToolUseError ? toolUseErrorText : text + const summary = displayText !== null ? generateOutputSummary(displayText) : null + + const header = toolUseError?.isToolUseError + ? '⛔ Tool rejected' + : props.isError + ? '❌ Tool error' + : '✓ Tool result' const hasContent = props.content !== null && props.content !== undefined return ( @@ -302,8 +379,8 @@ function ToolResultView(props: { isError: boolean; content: unknown }) { {header}
- {text !== null ? ( - + {displayText !== null ? ( + ) : hasContent ? ( ) : ( @@ -393,6 +470,9 @@ function renderOutputData(data: unknown): ReactNode { if (outputType === 'event') { const event = (data.data ?? data.event ?? data) as unknown + if (isObject(event) && event.type === 'ready') { + return null + } return (
{formatEventLabel(event)} @@ -434,6 +514,13 @@ function renderOutputData(data: unknown): ReactNode { if (outputType === 'tool_use') { const name = getToolName(data) const input = getToolInput(data) + if (name === 'mcp__happy__change_title' && isObject(input) && typeof input.title === 'string') { + return ( +
+ Title changed to "{input.title}" +
+ ) + } // Special handling for ExitPlanMode - show plan content directly if (isExitPlanModeTool(name)) { return @@ -456,6 +543,14 @@ function renderBlock(block: unknown): ReactNode { if (parsed !== block) { return renderBlock(parsed) } + const usageLimit = parseClaudeUsageLimit(block) + if (usageLimit !== null) { + return ( +
+ ⏳ Usage limit reached until {formatUnixTimestamp(usageLimit)} +
+ ) + } return } @@ -486,6 +581,14 @@ function renderBlock(block: unknown): ReactNode { const type = block.type if (type === 'text' && typeof block.text === 'string') { + const usageLimit = parseClaudeUsageLimit(block.text) + if (usageLimit !== null) { + return ( +
+ ⏳ Usage limit reached until {formatUnixTimestamp(usageLimit)} +
+ ) + } return } @@ -494,6 +597,9 @@ function renderBlock(block: unknown): ReactNode { } if (type === 'event') { + if (isObject(block.data) && block.data.type === 'ready') { + return null + } return (
{formatEventLabel(block.data)} @@ -508,6 +614,13 @@ function renderBlock(block: unknown): ReactNode { if (type === 'tool_use') { const name = getToolName(block) const input = getToolInput(block) + if (name === 'mcp__happy__change_title' && isObject(input) && typeof input.title === 'string') { + return ( +
+ Title changed to "{input.title}" +
+ ) + } // Special handling for ExitPlanMode - show plan content directly if (isExitPlanModeTool(name)) { return @@ -600,6 +713,9 @@ export function MessageBubble(props: { // Events render centered without bubble if (isObject(inner) && inner.type === 'event') { + if (isObject(inner.data) && inner.data.type === 'ready') { + return null + } return (
{renderBlock(inner)} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index a73aced6..dce5e4de 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1,23 +1,16 @@ +import { useEffect, useMemo, useRef, useState } from 'react' import type { ApiClient } from '@/api/client' -import type { AgentStateRequest, DecryptedMessage, Session } from '@/types/api' +import type { DecryptedMessage, Session } from '@/types/api' +import type { NormalizedMessage } from '@/chat/types' +import { normalizeDecryptedMessage } from '@/chat/normalize' +import { reduceChatBlocks } from '@/chat/reducer' import { Button } from '@/components/ui/button' import { SessionHeader } from '@/components/SessionHeader' -import { PermissionPanel } from '@/components/PermissionPanel' import { MessageBubble } from '@/components/MessageBubble' +import { ChatBlockList } from '@/components/ChatBlockList' import { ChatInput } from '@/components/ChatInput' import { useScrollToBottom } from '@/hooks/useScrollToBottom' -function getFirstPendingRequest(session: Session): { requestId: string; request: AgentStateRequest } | null { - const requests = session.agentState?.requests ?? null - if (!requests) return null - - const entries = Object.entries(requests) - if (entries.length === 0) return null - - const [requestId, request] = entries[0] - return { requestId, request } -} - export function SessionChat(props: { api: ApiClient session: Session @@ -33,9 +26,41 @@ export function SessionChat(props: { onSend: (text: string) => void onRetryMessage?: (localId: string) => void }) { - const pending = getFirstPendingRequest(props.session) - const scrollRef = useScrollToBottom([props.messages.length]) const controlsDisabled = !props.session.active + const normalizedCacheRef = useRef>(new Map()) + + useEffect(() => { + normalizedCacheRef.current.clear() + }, [props.session.id]) + + const normalizedMessages: NormalizedMessage[] = useMemo(() => { + const cache = normalizedCacheRef.current + const normalized: NormalizedMessage[] = [] + const seen = new Set() + for (const message of props.messages) { + seen.add(message.id) + const cached = cache.get(message.id) + if (cached && cached.source === message) { + if (cached.normalized) normalized.push(cached.normalized) + continue + } + const next = normalizeDecryptedMessage(message) + cache.set(message.id, { source: message, normalized: next }) + if (next) normalized.push(next) + } + for (const id of cache.keys()) { + if (!seen.has(id)) { + cache.delete(id) + } + } + return normalized + }, [props.messages]) + + const reduced = useMemo(() => reduceChatBlocks(normalizedMessages, props.session.agentState), [normalizedMessages, props.session.agentState]) + + const [debugViewMode, setDebugViewMode] = useState<'reduced' | 'raw'>('reduced') + const viewMode = import.meta.env.DEV ? debugViewMode : 'reduced' + const scrollRef = useScrollToBottom([props.messages.length, reduced.blocks.length, viewMode]) return (
@@ -52,55 +77,85 @@ export function SessionChat(props: {
) : null} -
- {props.messagesWarning ? ( -
- {props.messagesWarning} -
- ) : null} +
+
+ {props.messagesWarning ? ( +
+ {props.messagesWarning} +
+ ) : null} - {props.hasMoreMessages ? ( -
- -
- ) : null} + {import.meta.env.DEV ? ( +
+ + +
+ ) : null} - {props.isLoadingMessages ? ( -
Loading…
- ) : ( -
- {props.messages.map((m) => ( - props.onRetryMessage!(m.localId!) - : undefined - } - /> - ))} -
- )} + {props.hasMoreMessages ? ( +
+ +
+ ) : null} + + {props.isLoadingMessages ? ( +
Loading…
+ ) : ( + <> + {import.meta.env.DEV && viewMode === 'reduced' && normalizedMessages.length === 0 && props.messages.length > 0 ? ( +
+ Message normalization returned 0 items for {props.messages.length} messages (see `hapi/web/src/chat/normalize.ts`). +
+ ) : null} + + {viewMode === 'raw' ? ( +
+ {props.messages.map((m) => ( + props.onRetryMessage!(m.localId!) + : undefined + } + /> + ))} +
+ ) : ( + + )} + + )} +
- {pending ? ( - - ) : null} - { + return Boolean(value) && typeof value === 'object' +} + +function getInputStringAny(input: unknown, keys: string[]): string | null { + if (!isObject(input)) return null + for (const key of keys) { + const value = input[key] + if (typeof value === 'string' && value.length > 0) return value + } + return null +} + +function isToolAllowedForSession(toolName: string, toolInput: unknown, allowedTools: string[] | undefined): boolean { + if (!allowedTools || allowedTools.length === 0) return false + if (allowedTools.includes(toolName)) return true + + if (toolName === 'Bash') { + const command = getInputStringAny(toolInput, ['command', 'cmd']) + if (command) { + return allowedTools.includes(`Bash(${command})`) + } + } + + return false +} + +function isCodexSession(metadata: SessionMetadataSummary | null, toolName: string): boolean { + return metadata?.flavor === 'codex' || toolName.startsWith('Codex') +} + +function formatPermissionSummary(permission: ToolPermission, toolName: string, toolInput: unknown, codex: boolean): string { + if (permission.status === 'pending') return 'Waiting for approval…' + if (permission.status === 'canceled') return permission.reason ? `Canceled: ${permission.reason}` : 'Canceled' + + if (codex) { + if (permission.status === 'approved' && permission.decision === 'approved_for_session') return 'Approved for session' + if (permission.status === 'approved') return 'Approved' + if (permission.status === 'denied' && permission.decision === 'abort') return permission.reason ? `Aborted: ${permission.reason}` : 'Aborted' + if (permission.status === 'denied') return permission.reason ? `Denied: ${permission.reason}` : 'Denied' + return 'Permission' + } + + if (permission.status === 'approved') { + if (permission.mode === 'acceptEdits') return 'Approved: Allow all edits' + if (isToolAllowedForSession(toolName, toolInput, permission.allowedTools)) return 'Approved: Allow for session' + return 'Approved' + } + + if (permission.status === 'denied') { + return permission.reason ? `Denied: ${permission.reason}` : 'Denied' + } + + return 'Permission' +} + +function SpinnerIcon(props: { className?: string }) { + return ( + + + + + ) +} + +function PermissionRowButton(props: { + label: string + tone: 'allow' | 'deny' | 'neutral' + loading?: boolean + disabled: boolean + onClick: () => void +}) { + const base = 'flex w-full items-center justify-between rounded-md px-2 py-2 text-sm text-left transition-colors disabled:pointer-events-none disabled:opacity-50 hover:bg-[var(--app-subtle-bg)]' + const tone = props.tone === 'allow' + ? 'text-emerald-600' + : props.tone === 'deny' + ? 'text-red-600' + : 'text-[var(--app-link)]' + + return ( + + ) +} + +export function PermissionFooter(props: { + api: ApiClient + sessionId: string + metadata: SessionMetadataSummary | null + tool: ChatToolCall + disabled: boolean + onDone: () => void +}) { + const permission = props.tool.permission + const [loading, setLoading] = useState<'allow' | 'deny' | 'abort' | null>(null) + const [loadingAllEdits, setLoadingAllEdits] = useState(false) + const [loadingForSession, setLoadingForSession] = useState(false) + const [error, setError] = useState(null) + + const codex = useMemo(() => isCodexSession(props.metadata, props.tool.name), [props.metadata, props.tool.name]) + + if (!permission) return null + + const summary = formatPermissionSummary(permission, props.tool.name, props.tool.input, codex) + const isPending = permission.status === 'pending' + + const run = async (action: () => Promise, haptic: 'success' | 'error') => { + if (props.disabled) return + setError(null) + try { + await action() + getTelegramWebApp()?.HapticFeedback?.notificationOccurred(haptic) + props.onDone() + } catch (e) { + getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error') + setError(e instanceof Error ? e.message : 'Request failed') + } + } + + const toolName = props.tool.name + const isEditTool = toolName === 'Edit' + || toolName === 'MultiEdit' + || toolName === 'Write' + || toolName === 'NotebookEdit' + const hideAllowForSession = toolName === 'Edit' + || toolName === 'MultiEdit' + || toolName === 'Write' + || toolName === 'NotebookEdit' + || toolName === 'exit_plan_mode' + || toolName === 'ExitPlanMode' + + const canAllowForSession = !codex && isPending && !hideAllowForSession + const canAllowAllEdits = !codex && isPending && isEditTool + + const approve = async () => { + if (!isPending || loading || loadingAllEdits || loadingForSession) return + setLoading('allow') + await run(() => props.api.approvePermission(props.sessionId, permission.id), 'success') + setLoading(null) + } + + const approveAllEdits = async () => { + if (!isPending || loading || loadingAllEdits || loadingForSession) return + setLoadingAllEdits(true) + await run(() => props.api.approvePermission(props.sessionId, permission.id, 'acceptEdits'), 'success') + setLoadingAllEdits(false) + } + + const approveForSession = async () => { + if (!canAllowForSession || loading || loadingAllEdits || loadingForSession) return + setLoadingForSession(true) + const command = toolName === 'Bash' ? getInputStringAny(props.tool.input, ['command', 'cmd']) : null + const toolIdentifier = toolName === 'Bash' && command ? `Bash(${command})` : toolName + await run(() => props.api.approvePermission(props.sessionId, permission.id, { allowTools: [toolIdentifier] }), 'success') + setLoadingForSession(false) + } + + const deny = async () => { + if (!isPending || loading || loadingAllEdits || loadingForSession) return + setLoading('deny') + await run(() => props.api.denyPermission(props.sessionId, permission.id), 'success') + setLoading(null) + } + + const codexApprove = async (decision: 'approved' | 'approved_for_session') => { + if (!isPending || loading || loadingForSession) return + if (decision === 'approved_for_session') { + setLoadingForSession(true) + await run(() => props.api.approvePermission(props.sessionId, permission.id, { decision }), 'success') + setLoadingForSession(false) + return + } + setLoading('allow') + await run(() => props.api.approvePermission(props.sessionId, permission.id, { decision }), 'success') + setLoading(null) + } + + const codexAbort = async () => { + if (!isPending || loading || loadingForSession) return + setLoading('abort') + await run(() => props.api.denyPermission(props.sessionId, permission.id, { decision: 'abort' }), 'success') + setLoading(null) + } + + if (!isPending) { + const tone = permission.status === 'approved' + ? 'text-emerald-600' + : permission.status === 'denied' || permission.status === 'canceled' + ? 'text-red-600' + : 'text-[var(--app-hint)]' + + return ( +
+ {summary} +
+ ) + } + + return ( +
+
{summary}
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ {codex ? ( + <> + codexApprove('approved')} + /> + codexApprove('approved_for_session')} + /> + + + ) : ( + <> + + {canAllowForSession ? ( + + ) : null} + {canAllowAllEdits ? ( + + ) : null} + + + )} +
+
+ ) +} diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx new file mode 100644 index 00000000..9b73904c --- /dev/null +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -0,0 +1,460 @@ +import type { ToolCallBlock } from '@/chat/types' +import type { ApiClient } from '@/api/client' +import type { SessionMetadataSummary } from '@/types/api' +import { useEffect, useState, type ReactNode } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { CodeBlock } from '@/components/CodeBlock' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { DiffView } from '@/components/DiffView' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { PermissionFooter } from '@/components/ToolCard/PermissionFooter' +import { getToolPresentation } from '@/components/ToolCard/knownTools' + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +function parseToolUseError(message: string): { isToolUseError: boolean; errorMessage: string | null } { + const regex = /(.*?)<\/tool_use_error>/s + const match = message.match(regex) + + if (match) { + return { + isToolUseError: true, + errorMessage: typeof match[1] === 'string' ? match[1].trim() : '' + } + } + + return { isToolUseError: false, errorMessage: null } +} + +function getInputString(input: unknown, key: string): string | null { + if (!isObject(input)) return null + const value = input[key] + return typeof value === 'string' ? value : null +} + +function getInputStringAny(input: unknown, keys: string[]): string | null { + for (const key of keys) { + const value = getInputString(input, key) + if (value) return value + } + return null +} + +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text + return text.slice(0, maxLen - 3) + '...' +} + +function ElapsedView(props: { from: number; active: boolean }) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (!props.active) return + const id = setInterval(() => setNow(Date.now()), 250) + return () => clearInterval(id) + }, [props.active]) + + if (!props.active) return null + + const elapsed = (now - props.from) / 1000 + if (!Number.isFinite(elapsed)) return null + + return ( + + {elapsed.toFixed(1)}s + + ) +} + +function formatTaskChildLabel(child: ToolCallBlock): string { + const presentation = getToolPresentation({ + toolName: child.tool.name, + input: child.tool.input, + childrenCount: child.children.length, + description: child.tool.description + }) + + if (presentation.subtitle) { + return truncate(`${presentation.title}: ${presentation.subtitle}`, 140) + } + + return presentation.title +} + +function TaskStateIcon(props: { state: ToolCallBlock['tool']['state'] }) { + if (props.state === 'completed') { + return + } + if (props.state === 'error') { + return + } + if (props.state === 'pending') { + return 🔐 + } + return +} + +function renderTaskSummary(block: ToolCallBlock): ReactNode | null { + if (block.tool.name !== 'Task') return null + + const children = block.children + .filter((child): child is ToolCallBlock => child.kind === 'tool-call') + .filter((child) => child.tool.state === 'pending' || child.tool.state === 'running' || child.tool.state === 'completed' || child.tool.state === 'error') + + if (children.length === 0) return null + + const visible = children.slice(-3) + const remaining = children.length - visible.length + + return ( +
+
+ {visible.map((child) => ( +
+
+ + + + + {formatTaskChildLabel(child)} + +
+
+ ))} + {remaining > 0 ? ( +
+ (+{remaining} more) +
+ ) : null} +
+
+ ) +} + +function renderEditInput(input: unknown): ReactNode | null { + if (!isObject(input)) return null + const filePath = getInputStringAny(input, ['file_path', 'path']) ?? undefined + const oldString = getInputString(input, 'old_string') + const newString = getInputString(input, 'new_string') + if (oldString === null || newString === null) return null + + return ( + + ) +} + +function renderExitPlanModeInput(input: unknown): ReactNode | null { + if (!isObject(input)) return null + const plan = getInputString(input, 'plan') + if (!plan) return null + return +} + +function renderToolInput(block: ToolCallBlock): ReactNode { + const toolName = block.tool.name + const input = block.tool.input + + if (toolName === 'Task' && isObject(input) && typeof input.prompt === 'string') { + return + } + + if (toolName === 'Edit') { + const diff = renderEditInput(input) + if (diff) return diff + } + + if (toolName === 'MultiEdit' && isObject(input)) { + const filePath = getInputStringAny(input, ['file_path', 'path']) ?? undefined + const edits = Array.isArray(input.edits) ? input.edits : null + if (edits && edits.length > 0) { + const rendered = edits + .slice(0, 3) + .map((edit, idx) => { + if (!isObject(edit)) return null + const oldString = getInputString(edit, 'old_string') + const newString = getInputString(edit, 'new_string') + if (oldString === null || newString === null) return null + return ( +
+ +
+ ) + }) + .filter(Boolean) + + if (rendered.length > 0) { + return ( +
+ {rendered} + {edits.length > 3 ? ( +
+ (+{edits.length - 3} more edits) +
+ ) : null} +
+ ) + } + } + } + + if (toolName === 'Write' && isObject(input)) { + const filePath = getInputStringAny(input, ['file_path', 'path']) + const content = getInputStringAny(input, ['content', 'text']) + if (filePath && content !== null) { + return ( +
+
+ {filePath} +
+ +
+ ) + } + } + + if (toolName === 'CodexDiff' && isObject(input) && typeof input.unified_diff === 'string') { + return + } + + if (toolName === 'ExitPlanMode' || toolName === 'exit_plan_mode') { + const plan = renderExitPlanModeInput(input) + if (plan) return plan + } + + const commandArray = isObject(input) && Array.isArray(input.command) ? input.command : null + if ((toolName === 'CodexBash' || toolName === 'Bash') && (typeof commandArray?.[0] === 'string' || typeof input === 'object')) { + const cmd = Array.isArray(commandArray) + ? commandArray.filter((part) => typeof part === 'string').join(' ') + : getInputStringAny(input, ['command', 'cmd']) + if (cmd) { + return + } + } + + return +} + +function renderToolResult(block: ToolCallBlock): ReactNode { + const result = block.tool.result + const toolName = block.tool.name + + if (result === undefined || result === null) { + return ( +
+ {block.tool.state === 'pending' ? 'Waiting for permission…' : block.tool.state === 'running' ? 'Running…' : '(no output)'} +
+ ) + } + + if ((toolName === 'Bash' || toolName === 'CodexBash') && isObject(result)) { + const stdout = typeof result.stdout === 'string' ? result.stdout : null + const stderr = typeof result.stderr === 'string' ? result.stderr : null + if (stdout !== null || stderr !== null) { + return ( +
+ {stdout ? : null} + {stderr ? : null} +
+ ) + } + } + + if (typeof result === 'string') { + const toolUseError = parseToolUseError(result) + const display = toolUseError.isToolUseError ? (toolUseError.errorMessage ?? '') : result + return + } + + return +} + +function StatusIcon(props: { state: ToolCallBlock['tool']['state'] }) { + if (props.state === 'completed') { + return ( + + + + + ) + } + if (props.state === 'error') { + return ( + + + + + ) + } + if (props.state === 'pending') { + return ( + + + + + ) + } + return ( + + + + + ) +} + +function accentBorderClass(state: ToolCallBlock['tool']['state']): string { + if (state === 'completed') return 'border-l-4 border-l-emerald-500' + if (state === 'error') return 'border-l-4 border-l-red-500' + if (state === 'pending') return 'border-l-4 border-l-amber-500' + return 'border-l-4 border-l-blue-500' +} + +function statusColorClass(state: ToolCallBlock['tool']['state']): string { + if (state === 'completed') return 'text-emerald-600' + if (state === 'error') return 'text-red-600' + if (state === 'pending') return 'text-amber-600' + return 'text-[var(--app-hint)]' +} + +function DetailsIcon() { + return ( + + + + ) +} + +export function ToolCard(props: { + api: ApiClient + sessionId: string + metadata: SessionMetadataSummary | null + disabled: boolean + onDone: () => void + block: ToolCallBlock +}) { + const presentation = getToolPresentation({ + toolName: props.block.tool.name, + input: props.block.tool.input, + childrenCount: props.block.children.length, + description: props.block.tool.description + }) + + const toolName = props.block.tool.name + const toolTitle = presentation.title + const subtitle = presentation.subtitle ?? props.block.tool.description + const taskSummary = renderTaskSummary(props.block) + const accent = accentBorderClass(props.block.tool.state) + const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt + const showDialog = presentation.minimal || toolName === 'Task' + const showInline = !presentation.minimal && toolName !== 'Task' + const hasBody = showInline || taskSummary !== null || Boolean(props.block.tool.permission) + const stateColor = statusColorClass(props.block.tool.state) + + const header = ( +
+
+
+ {presentation.icon} +
+
+ {toolTitle} + {subtitle ? ( + + {truncate(subtitle, 160)} + + ) : null} +
+
+ +
+ + + + + {showDialog ? ( + + + + ) : null} +
+
+ ) + + return ( + + + {showDialog ? ( + + + + + + + {toolTitle} + +
+
+
Input
+ {renderToolInput(props.block)} +
+
+
Result
+ {renderToolResult(props.block)} +
+
+
+
+ ) : ( + header + )} +
+ + {hasBody ? ( + + {taskSummary ? ( +
+ {taskSummary} +
+ ) : null} + + {showInline ? ( +
+
+
Input
+ {renderToolInput(props.block)} +
+
+
Result
+ {renderToolResult(props.block)} +
+
+ ) : null} + + +
+ ) : null} +
+ ) +} diff --git a/web/src/components/ToolCard/knownTools.ts b/web/src/components/ToolCard/knownTools.ts new file mode 100644 index 00000000..65a29d08 --- /dev/null +++ b/web/src/components/ToolCard/knownTools.ts @@ -0,0 +1,219 @@ +export type ToolPresentation = { + icon: string + title: string + subtitle: string | null + minimal: boolean +} + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' +} + +function getInputStringAny(input: unknown, keys: string[]): string | null { + if (!isObject(input)) return null + for (const key of keys) { + const value = input[key] + if (typeof value === 'string' && value.length > 0) return value + } + return null +} + +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text + return text.slice(0, maxLen - 3) + '...' +} + +function snakeToTitleWithSpaces(value: string): string { + return value + .split('_') + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(' ') +} + +function formatMCPTitle(toolName: string): string { + const withoutPrefix = toolName.replace(/^mcp__/, '') + const parts = withoutPrefix.split('__') + if (parts.length >= 2) { + const serverName = snakeToTitleWithSpaces(parts[0]) + const toolPart = snakeToTitleWithSpaces(parts.slice(1).join('_')) + return `MCP: ${serverName} ${toolPart}` + } + return `MCP: ${snakeToTitleWithSpaces(withoutPrefix)}` +} + +type ToolOpts = { + toolName: string + input: unknown + childrenCount: number + description: string | null +} + +export const knownTools: Record string + subtitle?: (opts: ToolOpts) => string | null + minimal?: boolean | ((opts: ToolOpts) => boolean) +}> = { + Task: { + icon: '🚀', + title: (opts) => { + const description = getInputStringAny(opts.input, ['description']) + return description ?? 'Task' + }, + subtitle: (opts) => { + const prompt = getInputStringAny(opts.input, ['prompt']) + return prompt ? truncate(prompt, 120) : null + }, + minimal: (opts) => opts.childrenCount === 0 + }, + Bash: { + icon: '🖥️', + title: (opts) => opts.description ?? 'Terminal', + subtitle: (opts) => getInputStringAny(opts.input, ['command', 'cmd']), + minimal: true + }, + CodexBash: { + icon: '🖥️', + title: (opts) => opts.description ?? 'Terminal', + subtitle: (opts) => { + const command = getInputStringAny(opts.input, ['command', 'cmd']) + if (command) return command + if (isObject(opts.input) && Array.isArray(opts.input.command)) { + return opts.input.command.filter((part) => typeof part === 'string').join(' ') + } + return null + }, + minimal: true + }, + Read: { + icon: '👁️', + title: () => 'Read', + subtitle: (opts) => getInputStringAny(opts.input, ['file_path', 'path', 'file']), + minimal: true + }, + Edit: { + icon: '📝', + title: () => 'Edit', + subtitle: (opts) => getInputStringAny(opts.input, ['file_path', 'path']), + minimal: false + }, + MultiEdit: { + icon: '📝', + title: () => 'MultiEdit', + subtitle: (opts) => getInputStringAny(opts.input, ['file_path', 'path']), + minimal: false + }, + Write: { + icon: '📝', + title: () => 'Write', + subtitle: (opts) => getInputStringAny(opts.input, ['file_path', 'path']), + minimal: false + }, + WebFetch: { + icon: '🌐', + title: (opts) => { + const url = getInputStringAny(opts.input, ['url']) + if (!url) return 'Web fetch' + try { + return new URL(url).hostname + } catch { + return url + } + }, + subtitle: (opts) => { + const url = getInputStringAny(opts.input, ['url']) + if (!url) return null + return url + }, + minimal: true + }, + WebSearch: { + icon: '🌐', + title: (opts) => getInputStringAny(opts.input, ['query']) ?? 'Web search', + subtitle: (opts) => { + const query = getInputStringAny(opts.input, ['query']) + return query ? truncate(query, 80) : null + }, + minimal: true + }, + CodexPatch: { + icon: '🩹', + title: () => 'Apply changes', + subtitle: (opts) => { + if (isObject(opts.input) && isObject(opts.input.changes)) { + const files = Object.keys(opts.input.changes) + if (files.length === 0) return null + const first = files[0] + const basename = first.split('/').pop() ?? first + return files.length > 1 ? `${basename} (+${files.length - 1})` : basename + } + return null + }, + minimal: true + }, + CodexDiff: { + icon: '🧾', + title: () => 'Diff', + subtitle: (opts) => { + const unified = getInputStringAny(opts.input, ['unified_diff']) + if (!unified) return null + const lines = unified.split('\n') + for (const line of lines) { + if (line.startsWith('+++ b/') || line.startsWith('+++ ')) { + const fileName = line.replace(/^\+\+\+ (b\/)?/, '') + return fileName.split('/').pop() ?? fileName + } + } + return null + }, + minimal: false + }, + ExitPlanMode: { + icon: '📋', + title: () => 'Plan proposal', + minimal: false + }, + exit_plan_mode: { + icon: '📋', + title: () => 'Plan proposal', + minimal: false + } +} + +export function getToolPresentation(opts: ToolOpts): ToolPresentation { + if (opts.toolName.startsWith('mcp__')) { + return { + icon: '🔌', + title: formatMCPTitle(opts.toolName), + subtitle: null, + minimal: true + } + } + + const known = knownTools[opts.toolName] + if (known) { + const minimal = typeof known.minimal === 'function' ? known.minimal(opts) : (known.minimal ?? false) + return { + icon: known.icon, + title: known.title(opts), + subtitle: known.subtitle ? known.subtitle(opts) : null, + minimal + } + } + + const filePath = getInputStringAny(opts.input, ['file_path', 'path', 'filePath', 'file']) + const command = getInputStringAny(opts.input, ['command', 'cmd']) + const pattern = getInputStringAny(opts.input, ['pattern']) + const url = getInputStringAny(opts.input, ['url']) + const query = getInputStringAny(opts.input, ['query']) + + const subtitle = filePath ?? command ?? pattern ?? url ?? query + + return { + icon: '🔧', + title: opts.toolName, + subtitle: subtitle ? truncate(subtitle, 80) : null, + minimal: true + } +} diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx index 36af4d46..1aae1dd7 100644 --- a/web/src/components/ui/badge.tsx +++ b/web/src/components/ui/badge.tsx @@ -9,7 +9,8 @@ const badgeVariants = cva( variant: { default: 'border-[var(--app-border)] bg-[var(--app-subtle-bg)] text-[var(--app-fg)]', warning: 'border-[var(--app-badge-warning-border)] bg-[var(--app-badge-warning-bg)] text-[var(--app-badge-warning-text)]', - success: 'border-[var(--app-badge-success-border)] bg-[var(--app-badge-success-bg)] text-[var(--app-badge-success-text)]' + success: 'border-[var(--app-badge-success-border)] bg-[var(--app-badge-success-bg)] text-[var(--app-badge-success-text)]', + destructive: 'border-[var(--app-badge-error-border)] bg-[var(--app-badge-error-bg)] text-[var(--app-badge-error-text)]' } }, defaultVariants: { @@ -25,4 +26,3 @@ export interface BadgeProps export function Badge({ className, variant, ...props }: BadgeProps) { return
} - diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 0a03004f..c17f218b 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -14,7 +14,7 @@ export const DialogContent = React.forwardRef< )) DialogDescription.displayName = 'DialogDescription' - diff --git a/web/src/index.css b/web/src/index.css index a37c0e5f..d4d1fc38 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -30,6 +30,9 @@ --app-badge-success-bg: rgba(34, 197, 94, 0.2); --app-badge-success-text: #15803d; --app-badge-success-border: rgba(34, 197, 94, 0.3); + --app-badge-error-bg: rgba(239, 68, 68, 0.18); + --app-badge-error-text: #b91c1c; + --app-badge-error-border: rgba(239, 68, 68, 0.3); } [data-theme="dark"] { @@ -51,6 +54,9 @@ --app-badge-success-bg: rgba(74, 222, 128, 0.2); --app-badge-success-text: #4ade80; --app-badge-success-border: rgba(74, 222, 128, 0.3); + --app-badge-error-bg: rgba(248, 113, 113, 0.2); + --app-badge-error-text: #fca5a5; + --app-badge-error-border: rgba(248, 113, 113, 0.35); } html, diff --git a/web/src/types/api.ts b/web/src/types/api.ts index be745b00..3e7bb562 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -19,9 +19,22 @@ export type AgentStateRequest = { createdAt?: number | null } +export type AgentStateCompletedRequest = { + tool: string + arguments: unknown + createdAt?: number | null + completedAt?: number | null + status: 'canceled' | 'denied' | 'approved' + reason?: string + mode?: string + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + allowTools?: string[] +} + export type AgentState = { controlledByUser?: boolean | null requests?: Record | null + completedRequests?: Record | null } export type Session = {