rowboat/apps/x/apps/renderer/src/lib/chat-conversation.ts
2026-05-06 21:44:04 +05:30

661 lines
22 KiB
TypeScript

import type { ToolUIPart } from 'ai'
import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
export interface MessageAttachment {
path: string
filename: string
mimeType: string
size?: number
thumbnailUrl?: string
}
export interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
attachments?: MessageAttachment[]
timestamp: number
}
export interface ToolCall {
id: string
name: string
input: ToolUIPart['input']
result?: ToolUIPart['output']
streamingOutput?: string
status: 'pending' | 'running' | 'completed' | 'error'
timestamp: number
}
export interface ErrorMessage {
id: string
kind: 'error'
message: string
timestamp: number
}
export type ConversationItem = ChatMessage | ToolCall | ErrorMessage
export type PermissionResponse = 'approve' | 'deny'
export type ChatTabViewState = {
runId: string | null
conversation: ConversationItem[]
currentAssistantMessage: string
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
permissionResponses: Map<string, PermissionResponse>
}
export type ChatViewportAnchorState = {
messageId: string | null
requestKey: number
}
export const createEmptyChatTabViewState = (): ChatTabViewState => ({
runId: null,
conversation: [],
currentAssistantMessage: '',
pendingAskHumanRequests: new Map(),
allPermissionRequests: new Map(),
permissionResponses: new Map(),
})
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
export const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
export const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
export const isErrorMessage = (item: ConversationItem): item is ErrorMessage =>
'kind' in item && item.kind === 'error'
export const toToolState = (status: ToolCall['status']): ToolState => {
switch (status) {
case 'pending':
return 'input-streaming'
case 'running':
return 'input-available'
case 'completed':
return 'output-available'
case 'error':
return 'output-error'
default:
return 'input-available'
}
}
export const normalizeToolInput = (
input: ToolCall['input'] | string | undefined
): ToolCall['input'] => {
if (input === undefined || input === null) return {}
if (typeof input === 'string') {
const trimmed = input.trim()
if (!trimmed) return {}
try {
return JSON.parse(trimmed)
} catch {
return input
}
}
return input
}
export const normalizeToolOutput = (
output: ToolCall['result'] | undefined,
status: ToolCall['status']
) => {
if (output === undefined || output === null) {
return status === 'completed' ? 'No output returned.' : null
}
if (output === '') return '(empty output)'
if (typeof output === 'boolean' || typeof output === 'number') return String(output)
return output
}
export type WebSearchCardResult = { title: string; url: string; description: string }
export type WebSearchCardData = {
query: string
results: WebSearchCardResult[]
title?: string
}
export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null => {
if (tool.name === 'web-search') {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
const rawResults = (result?.results as Array<{
title: string
url: string
description?: string
highlights?: string[]
text?: string
}>) || []
const mapped = rawResults.map((entry) => ({
title: entry.title,
url: entry.url,
description: entry.description || entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
}))
const category = input?.category as string | undefined
return {
query: (input?.query as string) || '',
results: mapped,
title: (!category || category === 'general')
? 'Web search'
: `${category.charAt(0).toUpperCase() + category.slice(1)} search`,
}
}
return null
}
// App navigation action card data
export type AppActionCardData = {
action: string
label: string
details?: Record<string, unknown>
}
const summarizeFilterUpdates = (updates: Record<string, unknown>): string => {
const filters = updates.filters as Record<string, unknown> | undefined
const parts: string[] = []
if (filters) {
if (filters.clear) parts.push('Cleared filters')
const set = filters.set as Array<{ category: string; value: string }> | undefined
if (set?.length) parts.push(`Set ${set.length} filter${set.length !== 1 ? 's' : ''}: ${set.map(f => `${f.category}=${f.value}`).join(', ')}`)
const add = filters.add as Array<{ category: string; value: string }> | undefined
if (add?.length) parts.push(`Added ${add.length} filter${add.length !== 1 ? 's' : ''}`)
const remove = filters.remove as Array<{ category: string; value: string }> | undefined
if (remove?.length) parts.push(`Removed ${remove.length} filter${remove.length !== 1 ? 's' : ''}`)
}
if (updates.sort) {
const sort = updates.sort as { field: string; dir: string }
parts.push(`Sorted by ${sort.field} ${sort.dir}`)
}
if (updates.search !== undefined) {
parts.push(updates.search ? `Searching "${updates.search}"` : 'Cleared search')
}
const columns = updates.columns as Record<string, unknown> | undefined
if (columns) {
const set = columns.set as string[] | undefined
if (set) parts.push(`Set ${set.length} column${set.length !== 1 ? 's' : ''}`)
const add = columns.add as string[] | undefined
if (add?.length) parts.push(`Added ${add.length} column${add.length !== 1 ? 's' : ''}`)
const remove = columns.remove as string[] | undefined
if (remove?.length) parts.push(`Removed ${remove.length} column${remove.length !== 1 ? 's' : ''}`)
}
return parts.length > 0 ? parts.join(', ') : 'Updated view'
}
export const getAppActionCardData = (tool: ToolCall): AppActionCardData | null => {
if (tool.name !== 'app-navigation') return null
const result = tool.result as Record<string, unknown> | undefined
// While pending/running, derive label from input
if (!result || !result.success) {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
if (!input) return null
const action = input.action as string
switch (action) {
case 'open-note': return { action, label: `Opening ${(input.path as string || '').split('/').pop()?.replace(/\.md$/, '') || 'note'}...` }
case 'open-view': return { action, label: `Opening ${input.view} view...` }
case 'update-base-view': return { action, label: 'Updating view...' }
case 'create-base': return { action, label: `Creating "${input.name}"...` }
case 'get-base-state': return null // renders as normal tool block
default: return null
}
}
switch (result.action) {
case 'open-note': {
const filePath = result.path as string || ''
const name = filePath.split('/').pop()?.replace(/\.md$/, '') || 'note'
return { action: 'open-note', label: `Opened ${name}` }
}
case 'open-view':
return { action: 'open-view', label: `Opened ${result.view} view` }
case 'update-base-view':
return {
action: 'update-base-view',
label: summarizeFilterUpdates(result.updates as Record<string, unknown> || {}),
details: result.updates as Record<string, unknown>,
}
case 'create-base':
return { action: 'create-base', label: `Created base "${result.name}"` }
default:
return null // get-base-state renders as normal tool block
}
}
const BROWSER_PENDING_LABELS: Record<string, string> = {
open: 'Opening browser...',
'get-state': 'Reading browser state...',
'new-tab': 'Opening new browser tab...',
'switch-tab': 'Switching browser tab...',
'close-tab': 'Closing browser tab...',
navigate: 'Navigating browser...',
back: 'Going back...',
forward: 'Going forward...',
reload: 'Reloading page...',
'read-page': 'Reading page...',
click: 'Clicking page element...',
type: 'Typing into page...',
press: 'Sending key press...',
scroll: 'Scrolling page...',
wait: 'Waiting for page...',
}
const truncateLabel = (value: string, max = 72): string => {
const normalized = value.replace(/\s+/g, ' ').trim()
if (normalized.length <= max) return normalized
return `${normalized.slice(0, Math.max(0, max - 3)).trim()}...`
}
const safeBrowserString = (value: unknown): string | null => {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
const parseBrowserUrl = (value: string | null): URL | null => {
if (!value) return null
try {
return new URL(value)
} catch {
return null
}
}
const getGoogleSearchQuery = (value: string | null): string | null => {
const parsed = parseBrowserUrl(value)
if (!parsed) return null
const hostname = parsed.hostname.replace(/^www\./, '')
if (hostname !== 'google.com' && !hostname.endsWith('.google.com')) return null
if (parsed.pathname !== '/search') return null
const query = parsed.searchParams.get('q')?.trim()
return query ? truncateLabel(query, 56) : null
}
const formatBrowserTarget = (value: string | null): string | null => {
const parsed = parseBrowserUrl(value)
if (!parsed) {
return value ? truncateLabel(value, 56) : null
}
const hostname = parsed.hostname.replace(/^www\./, '')
const path = parsed.pathname === '/' ? '' : parsed.pathname
const suffix = parsed.search ? `${path}${parsed.search}` : path
return truncateLabel(`${hostname}${suffix}`, 56)
}
const sanitizeBrowserDescription = (value: string | null): string | null => {
if (!value) return null
let text = value
.replace(/^(clicked|typed into|pressed)\s+/i, '')
.replace(/\.$/, '')
.replace(/\s+/g, ' ')
.trim()
if (!text) return null
const looksLikeCssNoise =
/(^|[\s"])(body|html)\b/i.test(text)
|| /display:|position:|background-color|align-items|justify-content|z-index|var\(--|left:|top:/i.test(text)
|| /\.[A-Za-z0-9_-]+\{/.test(text)
if (looksLikeCssNoise || text.length > 88) {
const quoted = Array.from(text.matchAll(/"([^"]+)"/g))
.map((match) => match[1]?.trim())
.find((candidate) => candidate && !/display:|position:|background-color|var\(--/i.test(candidate))
if (!quoted) return null
text = `"${truncateLabel(quoted, 44)}"`
}
if (/^(body|html)\b/i.test(text)) return null
return truncateLabel(text, 64)
}
const getBrowserSuccessLabel = (
action: string,
input: Record<string, unknown> | undefined,
result: Record<string, unknown> | undefined,
): string | null => {
const page = result?.page as Record<string, unknown> | undefined
const pageUrl = safeBrowserString(page?.url)
const resultMessage = safeBrowserString(result?.message)
switch (action) {
case 'open':
return 'Opened browser'
case 'get-state':
return 'Read browser state'
case 'new-tab': {
const query = getGoogleSearchQuery(pageUrl)
if (query) return `Opened search for "${query}"`
const target = formatBrowserTarget(pageUrl) || safeBrowserString(input?.target)
return target ? `Opened ${target}` : 'Opened new tab'
}
case 'switch-tab':
return 'Switched browser tab'
case 'close-tab':
return 'Closed browser tab'
case 'navigate': {
const query = getGoogleSearchQuery(pageUrl)
if (query) return `Searched Google for "${query}"`
const target = formatBrowserTarget(pageUrl) || formatBrowserTarget(safeBrowserString(input?.target))
return target ? `Opened ${target}` : 'Navigated browser'
}
case 'back':
return 'Went back'
case 'forward':
return 'Went forward'
case 'reload':
return 'Reloaded page'
case 'read-page': {
const title = safeBrowserString(page?.title)
return title ? `Read ${truncateLabel(title, 52)}` : 'Read page'
}
case 'click': {
const detail = sanitizeBrowserDescription(resultMessage)
if (detail) return `Clicked ${detail}`
if (typeof input?.index === 'number') return `Clicked element ${input.index}`
return 'Clicked page element'
}
case 'type': {
const detail = sanitizeBrowserDescription(resultMessage)
if (detail) return `Typed into ${detail}`
if (typeof input?.index === 'number') return `Typed into element ${input.index}`
return 'Typed into page'
}
case 'press': {
const key = safeBrowserString(input?.key)
return key ? `Pressed ${truncateLabel(key, 20)}` : 'Sent key press'
}
case 'scroll':
return `Scrolled ${input?.direction === 'up' ? 'up' : 'down'}`
case 'wait': {
const ms = typeof input?.ms === 'number' ? input.ms : 1000
return `Waited ${ms}ms`
}
default:
return resultMessage ? truncateLabel(resultMessage, 72) : 'Controlled browser'
}
}
export const getBrowserControlLabel = (tool: ToolCall): string | null => {
if (tool.name !== 'browser-control') return null
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
const action = (input?.action as string | undefined) || (result?.action as string | undefined) || 'browser'
if (tool.status !== 'completed') {
if (action === 'click' && typeof input?.index === 'number') {
return `Clicking element ${input.index}...`
}
if (action === 'type' && typeof input?.index === 'number') {
return `Typing into element ${input.index}...`
}
if (action === 'navigate' && typeof input?.target === 'string') {
return `Navigating to ${input.target}...`
}
return BROWSER_PENDING_LABELS[action] || 'Controlling browser...'
}
if (result?.success === false) {
const error = safeBrowserString(result.error)
return error ? `Browser error: ${truncateLabel(error, 84)}` : 'Browser action failed'
}
const label = getBrowserSuccessLabel(action, input, result)
if (label) {
return label
}
return 'Controlled browser'
}
// Parse attached files from message content and return clean message + file paths.
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
const match = content.match(attachedFilesRegex)
if (!match) {
return { message: content, files: [] }
}
const filesXml = match[1]
const filePathRegex = /<file path="([^"]+)">/g
const files: string[] = []
let fileMatch
while ((fileMatch = filePathRegex.exec(filesXml)) !== null) {
files.push(fileMatch[1])
}
let cleanMessage = content.replace(attachedFilesRegex, '').trim()
for (const filePath of files) {
const fileName = filePath.split('/').pop()?.replace(/\.md$/i, '') || ''
if (!fileName) continue
const mentionRegex = new RegExp(`@${fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
cleanMessage = cleanMessage.replace(mentionRegex, '')
}
return { message: cleanMessage.trim(), files }
}
// Composio connect card data
export type ComposioConnectCardData = {
toolkitSlug: string
toolkitDisplayName: string
alreadyConnected: boolean
/** When true, the connect card should not be rendered (toolkit was already connected). */
hidden: boolean
}
export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardData | null => {
if (tool.name !== 'composio-connect-toolkit') return null
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
const toolkitSlug = (input?.toolkitSlug as string) || ''
const alreadyConnected = result?.alreadyConnected === true
return {
toolkitSlug,
toolkitDisplayName: COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug,
alreadyConnected,
// Don't render a connect card if the toolkit was already connected —
// the original card from the first connect call already shows the "Connected" state.
hidden: alreadyConnected,
}
}
// Human-friendly display names for builtin tools
const TOOL_DISPLAY_NAMES: Record<string, string> = {
'workspace-readFile': 'Reading file',
'workspace-writeFile': 'Writing file',
'workspace-edit': 'Editing file',
'workspace-readdir': 'Reading directory',
'workspace-exists': 'Checking path',
'workspace-stat': 'Getting file info',
'workspace-glob': 'Finding files',
'workspace-grep': 'Searching files',
'workspace-mkdir': 'Creating directory',
'workspace-rename': 'Renaming',
'workspace-copy': 'Copying file',
'workspace-remove': 'Removing',
'workspace-getRoot': 'Getting workspace root',
'loadSkill': 'Loading skill',
'parseFile': 'Parsing file',
'LLMParse': 'Extracting content',
'analyzeAgent': 'Analyzing agent',
'executeCommand': 'Running command',
'addMcpServer': 'Adding MCP server',
'listMcpServers': 'Listing MCP servers',
'listMcpTools': 'Listing MCP tools',
'executeMcpTool': 'Running MCP tool',
'web-search': 'Searching the web',
'save-to-memory': 'Saving to memory',
'app-navigation': 'Navigating app',
'browser-control': 'Controlling browser',
'composio-list-toolkits': 'Listing integrations',
'composio-search-tools': 'Searching tools',
'composio-execute-tool': 'Running tool',
'composio-connect-toolkit': 'Connecting service',
}
/**
* Get a human-friendly display name for a tool call.
* For Composio tools, returns a contextual label (e.g., "Found 3 tools for 'send email' in Gmail").
* For builtin tools, returns a static friendly name (e.g., "Reading file").
* Falls back to the raw tool name if no mapping exists.
*/
export const getToolDisplayName = (tool: ToolCall): string => {
const browserLabel = getBrowserControlLabel(tool)
if (browserLabel) return browserLabel
const composioData = getComposioActionCardData(tool)
if (composioData) return composioData.label
return TOOL_DISPLAY_NAMES[tool.name] || tool.name
}
// Composio action card data (for search, execute, list tools)
export type ComposioActionCardData = {
actionType: 'search' | 'execute' | 'list'
label: string
}
export const getComposioActionCardData = (tool: ToolCall): ComposioActionCardData | null => {
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const result = tool.result as Record<string, unknown> | undefined
if (tool.name === 'composio-search-tools') {
const query = (input?.query as string) || 'tools'
const toolkitSlug = input?.toolkitSlug as string | undefined
const toolkit = toolkitSlug ? COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug : null
const count = (result?.resultCount as number) ?? null
let label = `Searching for "${query}"`
if (toolkit) label += ` in ${toolkit}`
if (count !== null && tool.status === 'completed') {
label = count > 0 ? `Found ${count} tool${count !== 1 ? 's' : ''} for "${query}"` : `No tools found for "${query}"`
if (toolkit) label += ` in ${toolkit}`
}
return { actionType: 'search', label }
}
if (tool.name === 'composio-execute-tool') {
const toolSlug = (input?.toolSlug as string) || ''
const toolkitSlug = (input?.toolkitSlug as string) || ''
const toolkit = COMPOSIO_DISPLAY_NAMES[toolkitSlug] || toolkitSlug
const successful = result?.successful as boolean | undefined
// Make the tool slug human-readable: GITHUB_ISSUES_LIST_FOR_REPO → "Issues list for repo"
const readableName = toolSlug
.replace(/^[A-Z]+_/, '') // Remove toolkit prefix
.toLowerCase()
.replace(/_/g, ' ')
.replace(/^\w/, c => c.toUpperCase())
let label = `Running ${readableName}`
if (toolkit) label += ` on ${toolkit}`
if (tool.status === 'completed') {
label = successful === false ? `Failed: ${readableName}` : `${readableName}`
if (toolkit) label += ` on ${toolkit}`
}
return { actionType: 'execute', label }
}
if (tool.name === 'composio-list-toolkits') {
const count = (result?.totalCount as number) ?? null
const connected = (result?.connectedCount as number) ?? null
let label = 'Listing available integrations'
if (count !== null && tool.status === 'completed') {
label = `${count} integrations available`
if (connected !== null && connected > 0) label += `, ${connected} connected`
}
return { actionType: 'list', label }
}
return null
}
export type ToolGroup = {
type: 'tool-group'
items: ToolCall[]
groupId: string
}
export type GroupedConversationItem = ConversationItem | ToolGroup
export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
'type' in item && (item as ToolGroup).type === 'tool-group'
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
if (!isToolCall(item)) return false
if (getWebSearchCardData(item)) return false
if (getComposioConnectCardData(item)) return false
if (getAppActionCardData(item)) return false
return true
}
export const groupConversationItems = (
items: ConversationItem[],
hasPermissionRequest: (id: string) => boolean
): GroupedConversationItem[] => {
const result: GroupedConversationItem[] = []
let i = 0
while (i < items.length) {
const item = items[i]
if (isPlainToolCall(item) && !hasPermissionRequest(item.id)) {
const group: ToolCall[] = [item]
i++
while (
i < items.length &&
isPlainToolCall(items[i] as ConversationItem) &&
!hasPermissionRequest((items[i] as ToolCall).id)
) {
group.push(items[i] as ToolCall)
i++
}
if (group.length === 1) {
result.push(group[0])
} else {
result.push({ type: 'tool-group', items: group, groupId: group[0].id })
}
} else {
result.push(item)
i++
}
}
return result
}
export const getToolGroupSummary = (tools: ToolCall[]): string => {
const seen = new Set<string>()
const names: string[] = []
for (const tool of tools) {
const name = getToolDisplayName(tool)
if (!seen.has(name)) {
seen.add(name)
names.push(name)
}
}
return names.join(' · ')
}
export const inferRunTitleFromMessage = (content: string): string | undefined => {
const { message } = parseAttachedFiles(content)
const normalized = message.replace(/\s+/g, ' ').trim()
if (!normalized) return undefined
return normalized.length > 100 ? normalized.substring(0, 100) : normalized
}