diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index c47a841c..6e79bec8 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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> - allPermissionRequests: Map> - permissionResponses: Map -} - -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 = /\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 = //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 | 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(() => { 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 | undefined - const result = item.result as Record | undefined + const webSearchData = getWebSearchCardData(item) + if (webSearchData) { return ( ) || []} + query={webSearchData.query} + results={webSearchData.results} status={item.status} - /> - ) - } - if (item.name === 'research-search') { - const input = normalizeToolInput(item.input) as Record | undefined - const result = item.result as Record | 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 ( - ) } diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index dfb155b4..44fdbafd 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -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> - allPermissionRequests: Map> - permissionResponses: Map -} - -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> - allPermissionRequests?: Map> - permissionResponses?: Map - 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(() => ({ - runId: null, - conversation: [], - currentAssistantMessage: '', - pendingAskHumanRequests: new Map(), - allPermissionRequests: new Map(), - permissionResponses: new Map(), - }), []) + const emptyTabState = useMemo(() => 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 ( + + + {files.length > 0 && ( +
+ {files.map((filePath, index) => ( + + @{wikiLabel(filePath)} + + ))} +
+ )} + {message} +
+
+ ) + } return ( - {item.role === 'assistant' ? ( - {item.content} - ) : ( - item.content - )} + {item.content} ) } if (isToolCall(item)) { + const webSearchData = getWebSearchCardData(item) + if (webSearchData) { + return ( + + ) + } const errorText = item.status === 'error' ? 'Tool error' : '' const output = normalizeToolOutput(item.result, item.status) const input = normalizeToolInput(item.input) diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts new file mode 100644 index 00000000..4dcd4c8c --- /dev/null +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -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> + allPermissionRequests: Map> + permissionResponses: Map +} + +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 | undefined + const result = tool.result as Record | undefined + return { + query: (input?.query as string) || '', + results: (result?.results as WebSearchCardResult[]) || [], + } + } + + if (tool.name === 'research-search') { + const input = normalizeToolInput(tool.input) as Record | undefined + const result = tool.result as Record | 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 = /\s*([\s\S]*?)\s*<\/attached-files>/ + const match = content.match(attachedFilesRegex) + + if (!match) { + return { message: content, files: [] } + } + + const filesXml = match[1] + const filePathRegex = //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 +}