mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
feat: add permission decision options, tool allowlisting, and refactored chat UI components
This commit is contained in:
+21
-4
@@ -99,17 +99,34 @@ export class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
async approvePermission(sessionId: string, requestId: string, mode?: 'default' | 'acceptEdits' | 'bypassPermissions'): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async denyPermission(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
options?: {
|
||||
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
|
||||
}
|
||||
): Promise<void> {
|
||||
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(requestId)}/deny`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({})
|
||||
body: JSON.stringify(options ?? {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>,
|
||||
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<string, unknown>).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<string, unknown>) : 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<string, unknown>,
|
||||
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<string, unknown>).content : undefined
|
||||
const embeddedToolUseResult = 'toolUseResult' in data ? (data as Record<string, unknown>).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
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
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<string, PermissionEntry> {
|
||||
const map = new Map<string, PermissionEntry>()
|
||||
|
||||
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<string, ToolCallBlock>,
|
||||
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<string> {
|
||||
const ids = new Set<string>()
|
||||
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<string, PermissionEntry>
|
||||
groups: Map<string, TracedMessage[]>
|
||||
consumedGroupIds: Set<string>
|
||||
titleChangesByToolUseId: Map<string, string>
|
||||
emittedTitleChangeToolUseIds: Set<string>
|
||||
}
|
||||
): { blocks: ChatBlock[]; toolBlocksById: Map<string, ToolCallBlock>; hasReadyEvent: boolean } {
|
||||
const blocks: ChatBlock[] = []
|
||||
const toolBlocksById = new Map<string, ToolCallBlock>()
|
||||
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<string, TracedMessage[]>()
|
||||
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<string>()
|
||||
const emittedTitleChangeToolUseIds = new Set<string>()
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { NormalizedMessage } from '@/chat/types'
|
||||
|
||||
export type TracedMessage = NormalizedMessage & {
|
||||
sidechainId?: string
|
||||
}
|
||||
|
||||
type TracerState = {
|
||||
promptToTaskId: Map<string, string>
|
||||
uuidToSidechainId: Map<string, string>
|
||||
orphanMessages: Map<string, NormalizedMessage[]>
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
|
||||
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
|
||||
@@ -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 (
|
||||
<svg className="h-[14px] w-[14px]" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M8 5v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="8" cy="11" r="0.75" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageStatusIndicator(props: {
|
||||
status?: MessageStatus
|
||||
onRetry?: () => void
|
||||
}) {
|
||||
if (props.status !== 'failed') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-red-500">
|
||||
<ErrorIcon />
|
||||
</span>
|
||||
{props.onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onRetry}
|
||||
className="text-xs text-blue-500 hover:underline"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{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 (
|
||||
<div key={`user:${block.id}`} className={userBubbleClass}>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<MarkdownRenderer content={block.text} />
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="shrink-0 self-end pb-0.5">
|
||||
<MessageStatusIndicator status={status} onRetry={onRetry} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.kind === 'agent-text') {
|
||||
return (
|
||||
<div key={`agent:${block.id}`} className="px-1">
|
||||
<MarkdownRenderer content={block.text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.kind === 'agent-event') {
|
||||
return (
|
||||
<div key={`event:${block.id}`} className="py-1">
|
||||
<div className="mx-auto w-fit max-w-[92%] px-2 text-center text-xs text-[var(--app-hint)] opacity-80">
|
||||
{renderEventLabel(block)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.kind === 'tool-call') {
|
||||
const isTask = block.tool.name === 'Task'
|
||||
return (
|
||||
<div key={`tool:${block.id}`} className="py-1">
|
||||
<ToolCard
|
||||
api={props.api}
|
||||
sessionId={props.sessionId}
|
||||
metadata={props.metadata}
|
||||
disabled={props.disabled}
|
||||
onDone={props.onRefresh}
|
||||
block={block}
|
||||
/>
|
||||
{block.children.length > 0 ? (
|
||||
isTask ? (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-[var(--app-hint)]">
|
||||
Task details ({block.children.length})
|
||||
</summary>
|
||||
<div className="mt-2 border-l border-[var(--app-border)] pl-3">
|
||||
<ChatBlockList
|
||||
api={props.api}
|
||||
sessionId={props.sessionId}
|
||||
metadata={props.metadata}
|
||||
disabled={props.disabled}
|
||||
onRefresh={props.onRefresh}
|
||||
blocks={block.children}
|
||||
onRetryMessage={props.onRetryMessage}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
<div className="mt-2 border-l border-[var(--app-border)] pl-3">
|
||||
<ChatBlockList
|
||||
api={props.api}
|
||||
sessionId={props.sessionId}
|
||||
metadata={props.metadata}
|
||||
disabled={props.disabled}
|
||||
onRefresh={props.onRefresh}
|
||||
blocks={block.children}
|
||||
onRetryMessage={props.onRetryMessage}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>(.*?)<\/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, unknown>): 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}
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>🔧 {props.toolName}</DialogTitle>
|
||||
<DialogTitle>🔧 {title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-3 flex max-h-[60vh] flex-col gap-3 overflow-auto">
|
||||
{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 }) {
|
||||
<DialogTitle>{header}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-3 max-h-[60vh] overflow-auto">
|
||||
{text !== null ? (
|
||||
<CodeBlock code={text} language="text" />
|
||||
{displayText !== null ? (
|
||||
<CodeBlock code={displayText} language="text" />
|
||||
) : hasContent ? (
|
||||
<CodeBlock code={safeStringify(props.content)} language="json" />
|
||||
) : (
|
||||
@@ -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 (
|
||||
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
|
||||
{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 (
|
||||
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
|
||||
Title changed to "{input.title}"
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Special handling for ExitPlanMode - show plan content directly
|
||||
if (isExitPlanModeTool(name)) {
|
||||
return <ExitPlanModeView input={input} />
|
||||
@@ -456,6 +543,14 @@ function renderBlock(block: unknown): ReactNode {
|
||||
if (parsed !== block) {
|
||||
return renderBlock(parsed)
|
||||
}
|
||||
const usageLimit = parseClaudeUsageLimit(block)
|
||||
if (usageLimit !== null) {
|
||||
return (
|
||||
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
|
||||
⏳ Usage limit reached until {formatUnixTimestamp(usageLimit)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <MarkdownRenderer content={block} />
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
|
||||
⏳ Usage limit reached until {formatUnixTimestamp(usageLimit)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <MarkdownRenderer content={block.text} />
|
||||
}
|
||||
|
||||
@@ -494,6 +597,9 @@ function renderBlock(block: unknown): ReactNode {
|
||||
}
|
||||
|
||||
if (type === 'event') {
|
||||
if (isObject(block.data) && block.data.type === 'ready') {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
|
||||
{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 (
|
||||
<div className="mx-auto w-fit rounded-full bg-[var(--app-subtle-bg)] px-3 py-1 text-xs text-[var(--app-hint)]">
|
||||
Title changed to "{input.title}"
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Special handling for ExitPlanMode - show plan content directly
|
||||
if (isExitPlanModeTool(name)) {
|
||||
return <ExitPlanModeView input={input} />
|
||||
@@ -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 (
|
||||
<div className="py-1">
|
||||
{renderBlock(inner)}
|
||||
|
||||
@@ -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<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
normalizedCacheRef.current.clear()
|
||||
}, [props.session.id])
|
||||
|
||||
const normalizedMessages: NormalizedMessage[] = useMemo(() => {
|
||||
const cache = normalizedCacheRef.current
|
||||
const normalized: NormalizedMessage[] = []
|
||||
const seen = new Set<string>()
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -52,55 +77,85 @@ export function SessionChat(props: {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3">
|
||||
{props.messagesWarning ? (
|
||||
<div className="mb-3 rounded-md border border-amber-500/30 bg-amber-500/10 p-2 text-xs">
|
||||
{props.messagesWarning}
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[720px] p-3">
|
||||
{props.messagesWarning ? (
|
||||
<div className="mb-3 rounded-md border border-amber-500/30 bg-amber-500/10 p-2 text-xs">
|
||||
{props.messagesWarning}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{props.hasMoreMessages ? (
|
||||
<div className="mb-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={props.onLoadMore}
|
||||
disabled={props.isLoadingMoreMessages}
|
||||
>
|
||||
{props.isLoadingMoreMessages ? 'Loading…' : 'Load older'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{import.meta.env.DEV ? (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'reduced' ? 'default' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setDebugViewMode('reduced')}
|
||||
>
|
||||
Reduced
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'raw' ? 'default' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setDebugViewMode('raw')}
|
||||
>
|
||||
Raw
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{props.isLoadingMessages ? (
|
||||
<div className="text-sm text-[var(--app-hint)]">Loading…</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.messages.map((m) => (
|
||||
<MessageBubble
|
||||
key={m.id}
|
||||
message={m}
|
||||
onRetry={m.localId && m.status === 'failed' && props.onRetryMessage
|
||||
? () => props.onRetryMessage!(m.localId!)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{props.hasMoreMessages ? (
|
||||
<div className="mb-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={props.onLoadMore}
|
||||
disabled={props.isLoadingMoreMessages}
|
||||
>
|
||||
{props.isLoadingMoreMessages ? 'Loading…' : 'Load older'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{props.isLoadingMessages ? (
|
||||
<div className="text-sm text-[var(--app-hint)]">Loading…</div>
|
||||
) : (
|
||||
<>
|
||||
{import.meta.env.DEV && viewMode === 'reduced' && normalizedMessages.length === 0 && props.messages.length > 0 ? (
|
||||
<div className="mb-2 rounded-md border border-amber-500/30 bg-amber-500/10 p-2 text-xs">
|
||||
Message normalization returned 0 items for {props.messages.length} messages (see `hapi/web/src/chat/normalize.ts`).
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{viewMode === 'raw' ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.messages.map((m) => (
|
||||
<MessageBubble
|
||||
key={m.id}
|
||||
message={m}
|
||||
onRetry={m.localId && m.status === 'failed' && props.onRetryMessage
|
||||
? () => props.onRetryMessage!(m.localId!)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ChatBlockList
|
||||
api={props.api}
|
||||
sessionId={props.session.id}
|
||||
metadata={props.session.metadata}
|
||||
disabled={controlsDisabled}
|
||||
onRefresh={props.onRefresh}
|
||||
blocks={reduced.blocks}
|
||||
onRetryMessage={props.onRetryMessage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pending ? (
|
||||
<PermissionPanel
|
||||
api={props.api}
|
||||
sessionId={props.session.id}
|
||||
requestId={pending.requestId}
|
||||
request={pending.request}
|
||||
disabled={controlsDisabled}
|
||||
onDone={props.onRefresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ChatInput
|
||||
disabled={props.isSending || controlsDisabled}
|
||||
onSend={props.onSend}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { SessionMetadataSummary } from '@/types/api'
|
||||
import type { ChatToolCall, ToolPermission } from '@/chat/types'
|
||||
import { getTelegramWebApp } from '@/hooks/useTelegram'
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
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 (
|
||||
<svg className={props.className ?? 'h-4 w-4 animate-spin'} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.25" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" opacity="0.75" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`${base} ${tone}`}
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<span className="flex-1">{props.label}</span>
|
||||
{props.loading ? (
|
||||
<span className="ml-2 shrink-0">
|
||||
<SpinnerIcon />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | null>(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<void>, 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 (
|
||||
<div className={`mt-2 text-xs ${tone}`}>
|
||||
{summary}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-[var(--app-hint)]">{summary}</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{codex ? (
|
||||
<>
|
||||
<PermissionRowButton
|
||||
label="Yes"
|
||||
tone="allow"
|
||||
loading={loading === 'allow'}
|
||||
disabled={props.disabled || loading !== null || loadingForSession}
|
||||
onClick={() => codexApprove('approved')}
|
||||
/>
|
||||
<PermissionRowButton
|
||||
label="Yes for session"
|
||||
tone="neutral"
|
||||
loading={loadingForSession}
|
||||
disabled={props.disabled || loading !== null || loadingForSession}
|
||||
onClick={() => codexApprove('approved_for_session')}
|
||||
/>
|
||||
<PermissionRowButton
|
||||
label="Abort"
|
||||
tone="deny"
|
||||
loading={loading === 'abort'}
|
||||
disabled={props.disabled || loading !== null || loadingForSession}
|
||||
onClick={codexAbort}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PermissionRowButton
|
||||
label="Allow"
|
||||
tone="allow"
|
||||
loading={loading === 'allow'}
|
||||
disabled={props.disabled || loading !== null || loadingAllEdits || loadingForSession}
|
||||
onClick={approve}
|
||||
/>
|
||||
{canAllowForSession ? (
|
||||
<PermissionRowButton
|
||||
label="Allow for session"
|
||||
tone="neutral"
|
||||
loading={loadingForSession}
|
||||
disabled={props.disabled || loading !== null || loadingAllEdits || loadingForSession}
|
||||
onClick={approveForSession}
|
||||
/>
|
||||
) : null}
|
||||
{canAllowAllEdits ? (
|
||||
<PermissionRowButton
|
||||
label="Allow all edits"
|
||||
tone="neutral"
|
||||
loading={loadingAllEdits}
|
||||
disabled={props.disabled || loading !== null || loadingAllEdits || loadingForSession}
|
||||
onClick={approveAllEdits}
|
||||
/>
|
||||
) : null}
|
||||
<PermissionRowButton
|
||||
label="Deny"
|
||||
tone="deny"
|
||||
loading={loading === 'deny'}
|
||||
disabled={props.disabled || loading !== null || loadingAllEdits || loadingForSession}
|
||||
onClick={deny}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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>(.*?)<\/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 (
|
||||
<span className="font-mono text-xs text-[var(--app-hint)]">
|
||||
{elapsed.toFixed(1)}s
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 <span className="text-emerald-600">✓</span>
|
||||
}
|
||||
if (props.state === 'error') {
|
||||
return <span className="text-red-600">✕</span>
|
||||
}
|
||||
if (props.state === 'pending') {
|
||||
return <span className="text-amber-600">🔐</span>
|
||||
}
|
||||
return <span className="text-amber-600 animate-pulse">●</span>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-1 px-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
{visible.map((child) => (
|
||||
<div key={child.id} className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 font-mono text-xs text-[var(--app-hint)]">
|
||||
<span className="mr-2 inline-block w-4 text-center align-middle">
|
||||
<TaskStateIcon state={child.tool.state} />
|
||||
</span>
|
||||
<span className="align-middle break-all">
|
||||
{formatTaskChildLabel(child)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{remaining > 0 ? (
|
||||
<div className="text-xs text-[var(--app-hint)] italic">
|
||||
(+{remaining} more)
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<DiffView
|
||||
oldString={oldString}
|
||||
newString={newString}
|
||||
filePath={filePath}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderExitPlanModeInput(input: unknown): ReactNode | null {
|
||||
if (!isObject(input)) return null
|
||||
const plan = getInputString(input, 'plan')
|
||||
if (!plan) return null
|
||||
return <MarkdownRenderer content={plan} />
|
||||
}
|
||||
|
||||
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 <MarkdownRenderer content={input.prompt} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div key={idx}>
|
||||
<DiffView oldString={oldString} newString={newString} filePath={filePath} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (rendered.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{rendered}
|
||||
{edits.length > 3 ? (
|
||||
<div className="text-xs text-[var(--app-hint)]">
|
||||
(+{edits.length - 3} more edits)
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === 'Write' && isObject(input)) {
|
||||
const filePath = getInputStringAny(input, ['file_path', 'path'])
|
||||
const content = getInputStringAny(input, ['content', 'text'])
|
||||
if (filePath && content !== null) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-xs text-[var(--app-hint)] font-mono break-all">
|
||||
{filePath}
|
||||
</div>
|
||||
<CodeBlock code={content} language="text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === 'CodexDiff' && isObject(input) && typeof input.unified_diff === 'string') {
|
||||
return <CodeBlock code={input.unified_diff} language="diff" />
|
||||
}
|
||||
|
||||
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 <CodeBlock code={cmd} language="bash" />
|
||||
}
|
||||
}
|
||||
|
||||
return <CodeBlock code={safeStringify(input)} language="json" />
|
||||
}
|
||||
|
||||
function renderToolResult(block: ToolCallBlock): ReactNode {
|
||||
const result = block.tool.result
|
||||
const toolName = block.tool.name
|
||||
|
||||
if (result === undefined || result === null) {
|
||||
return (
|
||||
<div className="text-sm text-[var(--app-hint)]">
|
||||
{block.tool.state === 'pending' ? 'Waiting for permission…' : block.tool.state === 'running' ? 'Running…' : '(no output)'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
{stdout ? <CodeBlock code={stdout} language="text" /> : null}
|
||||
{stderr ? <CodeBlock code={stderr} language="text" /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof result === 'string') {
|
||||
const toolUseError = parseToolUseError(result)
|
||||
const display = toolUseError.isToolUseError ? (toolUseError.errorMessage ?? '') : result
|
||||
return <CodeBlock code={display} language="text" />
|
||||
}
|
||||
|
||||
return <CodeBlock code={safeStringify(result)} language="json" />
|
||||
}
|
||||
|
||||
function StatusIcon(props: { state: ToolCallBlock['tool']['state'] }) {
|
||||
if (props.state === 'completed') {
|
||||
return (
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M5.2 8.3l1.8 1.8 3.8-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (props.state === 'error') {
|
||||
return (
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M5.6 5.6l4.8 4.8M10.4 5.6l-4.8 4.8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (props.state === 'pending') {
|
||||
return (
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="4.5" y="7" width="7" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M6 7V5.8a2 2 0 0 1 4 0V7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.25" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" opacity="0.75" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<div className="shrink-0 text-base leading-none">
|
||||
{presentation.icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-sm font-medium break-words">{toolTitle}</CardTitle>
|
||||
{subtitle ? (
|
||||
<CardDescription className="mt-0.5 font-mono text-xs break-all opacity-80">
|
||||
{truncate(subtitle, 160)}
|
||||
</CardDescription>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<ElapsedView from={runningFrom} active={props.block.tool.state === 'running'} />
|
||||
<span className={stateColor}>
|
||||
<StatusIcon state={props.block.tool.state} />
|
||||
</span>
|
||||
{showDialog ? (
|
||||
<span className="text-[var(--app-hint)]">
|
||||
<DetailsIcon />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className={`overflow-hidden shadow-sm ${accent}`}>
|
||||
<CardHeader className="p-3 space-y-0">
|
||||
{showDialog ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button type="button" className="w-full text-left">
|
||||
{header}
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{toolTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-3 flex max-h-[75vh] flex-col gap-4 overflow-auto">
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">Input</div>
|
||||
{renderToolInput(props.block)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">Result</div>
|
||||
{renderToolResult(props.block)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
header
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{hasBody ? (
|
||||
<CardContent className="px-3 pb-3 pt-0">
|
||||
{taskSummary ? (
|
||||
<div className="mt-2">
|
||||
{taskSummary}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showInline ? (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">Input</div>
|
||||
{renderToolInput(props.block)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-[var(--app-hint)]">Result</div>
|
||||
{renderToolResult(props.block)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PermissionFooter
|
||||
api={props.api}
|
||||
sessionId={props.sessionId}
|
||||
metadata={props.metadata}
|
||||
tool={props.block.tool}
|
||||
disabled={props.disabled}
|
||||
onDone={props.onDone}
|
||||
/>
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
export type ToolPresentation = {
|
||||
icon: string
|
||||
title: string
|
||||
subtitle: string | null
|
||||
minimal: boolean
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
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, {
|
||||
icon: string
|
||||
title: (opts: ToolOpts) => 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
|
||||
}
|
||||
}
|
||||
@@ -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 <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-24px)] max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] p-4 shadow-lg',
|
||||
'fixed left-1/2 top-1/2 z-50 w-[calc(100vw-24px)] max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-[var(--app-secondary-bg)] p-4 shadow-2xl',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -50,4 +50,3 @@ export const DialogDescription = React.forwardRef<
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = 'DialogDescription'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, AgentStateRequest> | null
|
||||
completedRequests?: Record<string, AgentStateCompletedRequest> | null
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
|
||||
Reference in New Issue
Block a user