mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
refactor App and ChatSidebar components to centralize chat state management, enhance conversation item rendering with file attachments, and integrate web search results; streamline type definitions and utility functions for improved clarity
This commit is contained in:
parent
df32d3f822
commit
158e1863c9
3 changed files with 275 additions and 277 deletions
|
|
@ -52,6 +52,21 @@ import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
|||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
|
||||
import {
|
||||
type ChatTabViewState,
|
||||
type ConversationItem,
|
||||
type ToolCall,
|
||||
createEmptyChatTabViewState,
|
||||
getWebSearchCardData,
|
||||
inferRunTitleFromMessage,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
normalizeToolInput,
|
||||
normalizeToolOutput,
|
||||
parseAttachedFiles,
|
||||
toToolState,
|
||||
} from '@/lib/chat-conversation'
|
||||
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
|
||||
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
|
||||
import { toast } from "sonner"
|
||||
|
|
@ -65,70 +80,6 @@ interface TreeNode extends DirEntry {
|
|||
loaded?: boolean
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
input: ToolUIPart['input'];
|
||||
result?: ToolUIPart['output'];
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface ErrorMessage {
|
||||
id: string;
|
||||
kind: 'error';
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type ConversationItem = ChatMessage | ToolCall | ErrorMessage;
|
||||
|
||||
type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error';
|
||||
|
||||
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, 'approve' | 'deny'>
|
||||
}
|
||||
|
||||
const createEmptyChatTabViewState = (): ChatTabViewState => ({
|
||||
runId: null,
|
||||
conversation: [],
|
||||
currentAssistantMessage: '',
|
||||
pendingAskHumanRequests: new Map(),
|
||||
allPermissionRequests: new Map(),
|
||||
permissionResponses: new Map(),
|
||||
})
|
||||
|
||||
const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
|
||||
const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
|
||||
const isErrorMessage = (item: ConversationItem): item is ErrorMessage => 'kind' in item && item.kind === 'error'
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 256
|
||||
|
|
@ -155,48 +106,6 @@ const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
|
|||
const clampNumber = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value))
|
||||
|
||||
// Parse attached files from message content and return clean message + file paths
|
||||
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: [] }
|
||||
}
|
||||
|
||||
// Extract file paths from the XML
|
||||
const filesXml = match[1]
|
||||
const filePathRegex = /<file path="([^"]+)">/g
|
||||
const files: string[] = []
|
||||
let fileMatch
|
||||
while ((fileMatch = filePathRegex.exec(filesXml)) !== null) {
|
||||
files.push(fileMatch[1])
|
||||
}
|
||||
|
||||
// Remove the attached-files block
|
||||
let cleanMessage = content.replace(attachedFilesRegex, '').trim()
|
||||
|
||||
// Also remove @mentions for the attached files (they're shown as pills)
|
||||
for (const filePath of files) {
|
||||
// Get the display name (last part of path without extension)
|
||||
const fileName = filePath.split('/').pop()?.replace(/\.md$/i, '') || ''
|
||||
if (fileName) {
|
||||
// Remove @filename pattern (with optional trailing space)
|
||||
const mentionRegex = new RegExp(`@${fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
|
||||
cleanMessage = cleanMessage.replace(mentionRegex, '')
|
||||
}
|
||||
}
|
||||
|
||||
return { message: cleanMessage.trim(), files }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const untitledBaseName = 'untitled'
|
||||
|
||||
const getHeadingTitle = (markdown: string) => {
|
||||
|
|
@ -242,29 +151,6 @@ const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageMod
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Sort nodes (dirs first, then alphabetically)
|
||||
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
return nodes.sort((a, b) => {
|
||||
|
|
@ -498,6 +384,10 @@ function App() {
|
|||
// Global navigation history (back/forward) across views (chat/file/graph/task)
|
||||
const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] })
|
||||
const [viewHistory, setViewHistory] = useState(historyRef.current)
|
||||
const setHistory = useCallback((next: { back: ViewState[]; forward: ViewState[] }) => {
|
||||
historyRef.current = next
|
||||
setViewHistory(next)
|
||||
}, [])
|
||||
|
||||
// Auto-save state
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
|
@ -989,7 +879,7 @@ function App() {
|
|||
}
|
||||
}
|
||||
saveFile()
|
||||
}, [debouncedContent])
|
||||
}, [debouncedContent, setHistory])
|
||||
|
||||
// Load runs list (all pages)
|
||||
const loadRuns = useCallback(async () => {
|
||||
|
|
@ -1222,34 +1112,25 @@ function App() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Listen to run events
|
||||
// Listen to run events - use ref to avoid stale closure issues
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('runs:events', ((event: unknown) => {
|
||||
handleRunEvent(event as RunEventType)
|
||||
}) as (event: null) => void)
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
const getStreamingBuffer = (id: string) => {
|
||||
const getStreamingBuffer = useCallback((id: string) => {
|
||||
const existing = streamingBuffersRef.current.get(id)
|
||||
if (existing) return existing
|
||||
const next = { assistant: '' }
|
||||
streamingBuffersRef.current.set(id, next)
|
||||
return next
|
||||
}
|
||||
}, [])
|
||||
|
||||
const appendStreamingBuffer = (id: string, delta: string) => {
|
||||
const appendStreamingBuffer = useCallback((id: string, delta: string) => {
|
||||
if (!delta) return
|
||||
const buffer = getStreamingBuffer(id)
|
||||
buffer.assistant += delta
|
||||
}
|
||||
}, [getStreamingBuffer])
|
||||
|
||||
const clearStreamingBuffer = (id: string) => {
|
||||
const clearStreamingBuffer = useCallback((id: string) => {
|
||||
streamingBuffersRef.current.delete(id)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRunEvent = (event: RunEventType) => {
|
||||
const handleRunEvent = useCallback((event: RunEventType) => {
|
||||
const activeRunId = runIdRef.current
|
||||
const isActiveRun = event.runId === activeRunId
|
||||
|
||||
|
|
@ -1523,7 +1404,15 @@ function App() {
|
|||
console.error('Run error:', event.error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [appendStreamingBuffer, clearStreamingBuffer, loadRuns])
|
||||
|
||||
// Listen to run events - use refs/callbacks to avoid stale closure issues.
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('runs:events', ((event: unknown) => {
|
||||
handleRunEvent(event as RunEventType)
|
||||
}) as (event: null) => void)
|
||||
return cleanup
|
||||
}, [handleRunEvent])
|
||||
|
||||
const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => {
|
||||
if (isProcessing) return
|
||||
|
|
@ -1985,11 +1874,6 @@ function App() {
|
|||
}
|
||||
}, [expandedFrom])
|
||||
|
||||
const setHistory = useCallback((next: { back: ViewState[]; forward: ViewState[] }) => {
|
||||
historyRef.current = next
|
||||
setViewHistory(next)
|
||||
}, [])
|
||||
|
||||
const currentViewState = React.useMemo<ViewState>(() => {
|
||||
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
|
||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||
|
|
@ -2451,7 +2335,7 @@ function App() {
|
|||
onOpenInNewTab: (path: string) => {
|
||||
openFileInNewTab(path)
|
||||
},
|
||||
}), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab, removeEditorCacheForPath])
|
||||
}), [tree, selectedPath, workspaceRoot, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab, removeEditorCacheForPath])
|
||||
|
||||
// Handler for when a voice note is created/updated
|
||||
const handleVoiceNoteCreated = useCallback(async (notePath: string) => {
|
||||
|
|
@ -2660,36 +2544,15 @@ function App() {
|
|||
}
|
||||
|
||||
if (isToolCall(item)) {
|
||||
if (item.name === 'web-search') {
|
||||
const input = normalizeToolInput(item.input) as Record<string, unknown> | undefined
|
||||
const result = item.result as Record<string, unknown> | undefined
|
||||
const webSearchData = getWebSearchCardData(item)
|
||||
if (webSearchData) {
|
||||
return (
|
||||
<WebSearchResult
|
||||
key={item.id}
|
||||
query={(input?.query as string) || ''}
|
||||
results={(result?.results as Array<{ title: string; url: string; description: string }>) || []}
|
||||
query={webSearchData.query}
|
||||
results={webSearchData.results}
|
||||
status={item.status}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.name === 'research-search') {
|
||||
const input = normalizeToolInput(item.input) as Record<string, unknown> | undefined
|
||||
const result = item.result as Record<string, unknown> | undefined
|
||||
const rawResults = (result?.results as Array<{ title: string; url: string; highlights?: string[]; text?: string }>) || []
|
||||
const mapped = rawResults.map(r => ({
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
description: r.highlights?.[0] || (r.text ? r.text.slice(0, 200) : ''),
|
||||
}))
|
||||
const category = input?.category as string | undefined
|
||||
const cardTitle = category ? `${category.charAt(0).toUpperCase() + category.slice(1)} search` : 'Researched the web'
|
||||
return (
|
||||
<WebSearchResult
|
||||
key={item.id}
|
||||
query={(input?.query as string) || ''}
|
||||
results={mapped}
|
||||
status={item.status}
|
||||
title={cardTitle}
|
||||
title={webSearchData.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Expand, Shrink, SquarePen } from 'lucide-react'
|
||||
import type { ToolUIPart } from 'ai'
|
||||
import z from 'zod'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -19,93 +17,30 @@ import {
|
|||
} from '@/components/ai-elements/message'
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer'
|
||||
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
|
||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||
import { Suggestions } from '@/components/ai-elements/suggestions'
|
||||
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions } from '@/components/chat-input-with-mentions'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
id: string
|
||||
name: string
|
||||
input: ToolUIPart['input']
|
||||
result?: ToolUIPart['output']
|
||||
status: 'pending' | 'running' | 'completed' | 'error'
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface ErrorMessage {
|
||||
id: string
|
||||
kind: 'error'
|
||||
message: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
type ConversationItem = ChatMessage | ToolCall | ErrorMessage
|
||||
|
||||
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, 'approve' | 'deny'>
|
||||
}
|
||||
|
||||
type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
|
||||
|
||||
const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
|
||||
const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
|
||||
const isErrorMessage = (item: ConversationItem): item is ErrorMessage => 'kind' in item && item.kind === 'error'
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
import {
|
||||
type ChatTabViewState,
|
||||
type ConversationItem,
|
||||
type PermissionResponse,
|
||||
createEmptyChatTabViewState,
|
||||
getWebSearchCardData,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
isToolCall,
|
||||
normalizeToolInput,
|
||||
normalizeToolOutput,
|
||||
parseAttachedFiles,
|
||||
toToolState,
|
||||
} from '@/lib/chat-conversation'
|
||||
|
||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||
|
||||
|
|
@ -163,10 +98,10 @@ interface ChatSidebarProps {
|
|||
onPresetMessageConsumed?: () => void
|
||||
getInitialDraft?: (tabId: string) => string | undefined
|
||||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||
pendingAskHumanRequests?: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
||||
allPermissionRequests?: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
||||
permissionResponses?: Map<string, 'approve' | 'deny'>
|
||||
onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void
|
||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||
permissionResponses?: ChatTabViewState['permissionResponses']
|
||||
onPermissionResponse?: (toolCallId: string, subflow: string[], response: PermissionResponse) => void
|
||||
onAskHumanResponse?: (toolCallId: string, subflow: string[], response: string) => void
|
||||
isToolOpenForTab?: (tabId: string, toolId: string) => boolean
|
||||
onToolOpenChangeForTab?: (tabId: string, toolId: string, open: boolean) => void
|
||||
|
|
@ -305,14 +240,7 @@ export function ChatSidebar({
|
|||
allPermissionRequests,
|
||||
permissionResponses,
|
||||
])
|
||||
const emptyTabState = useMemo<ChatTabViewState>(() => ({
|
||||
runId: null,
|
||||
conversation: [],
|
||||
currentAssistantMessage: '',
|
||||
pendingAskHumanRequests: new Map(),
|
||||
allPermissionRequests: new Map(),
|
||||
permissionResponses: new Map(),
|
||||
}), [])
|
||||
const emptyTabState = useMemo<ChatTabViewState>(() => createEmptyChatTabViewState(), [])
|
||||
const getTabState = useCallback((tabId: string): ChatTabViewState => {
|
||||
if (tabId === activeChatTabId) return activeTabState
|
||||
return chatTabStates[tabId] ?? emptyTabState
|
||||
|
|
@ -321,20 +249,50 @@ export function ChatSidebar({
|
|||
|
||||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||
if (isChatMessage(item)) {
|
||||
if (item.role === 'user') {
|
||||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<MessageContent>
|
||||
{files.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{files.map((filePath, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
|
||||
>
|
||||
@{wikiLabel(filePath)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<MessageContent>
|
||||
{item.role === 'assistant' ? (
|
||||
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
|
||||
) : (
|
||||
item.content
|
||||
)}
|
||||
<MessageResponse components={streamdownComponents}>{item.content}</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
|
||||
if (isToolCall(item)) {
|
||||
const webSearchData = getWebSearchCardData(item)
|
||||
if (webSearchData) {
|
||||
return (
|
||||
<WebSearchResult
|
||||
key={item.id}
|
||||
query={webSearchData.query}
|
||||
results={webSearchData.results}
|
||||
status={item.status}
|
||||
title={webSearchData.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const errorText = item.status === 'error' ? 'Tool error' : ''
|
||||
const output = normalizeToolOutput(item.result, item.status)
|
||||
const input = normalizeToolInput(item.input)
|
||||
|
|
|
|||
177
apps/x/apps/renderer/src/lib/chat-conversation.ts
Normal file
177
apps/x/apps/renderer/src/lib/chat-conversation.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import type { ToolUIPart } from 'ai'
|
||||
import z from 'zod'
|
||||
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string
|
||||
name: string
|
||||
input: ToolUIPart['input']
|
||||
result?: ToolUIPart['output']
|
||||
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 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
|
||||
return {
|
||||
query: (input?.query as string) || '',
|
||||
results: (result?.results as WebSearchCardResult[]) || [],
|
||||
}
|
||||
}
|
||||
|
||||
if (tool.name === 'research-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
|
||||
highlights?: string[]
|
||||
text?: string
|
||||
}>) || []
|
||||
const mapped = rawResults.map((entry) => ({
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
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.charAt(0).toUpperCase() + category.slice(1)} search`
|
||||
: 'Researched the web',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue