feat: add permission decision options, tool allowlisting, and refactored chat UI components

This commit is contained in:
weishu
2025-12-17 07:57:37 +08:00
parent c2b886896b
commit 0fa05dc205
17 changed files with 2689 additions and 86 deletions
+21 -4
View File
@@ -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 ?? {})
})
}
+433
View File
@@ -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
}
}
+512
View File
@@ -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 }
}
+127
View File
@@ -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
}
+142
View File
@@ -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
+184
View File
@@ -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>
)
}
+123 -7
View File
@@ -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 &quot;{input.title}&quot;
</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 &quot;{input.title}&quot;
</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)}
+115 -60
View File
@@ -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>
)
}
+460
View File
@@ -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>
)
}
+219
View File
@@ -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
}
}
+2 -2
View File
@@ -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} />
}
+1 -2
View File
@@ -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'
+6
View File
@@ -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,
+13
View File
@@ -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 = {