diff --git a/README.md b/README.md
index 9ba7e099..640ee35c 100644
--- a/README.md
+++ b/README.md
@@ -141,11 +141,6 @@ Examples: Exa (web search), Twitter/X, ElevenLabs (voice), Slack, Linear/Jira, G
- No proprietary formats or hosted lock-in
- You can inspect, edit, back up, or delete everything at any time
-
-## Looking for Rowboat Web Studio?
-
-If you’re looking for Rowboat web Studio, start [here](https://docs.rowboatlabs.com/).
-
---
diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts
index 72e1e589..b0757881 100644
--- a/apps/x/apps/main/src/ipc.ts
+++ b/apps/x/apps/main/src/ipc.ts
@@ -30,6 +30,7 @@ import * as composioHandler from './composio-handler.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
+import { search } from '@x/core/dist/search/search.js';
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@@ -497,5 +498,9 @@ export function setupIpcHandlers() {
const mimeType = mimeMap[ext] || 'application/octet-stream';
return { data: buffer.toString('base64'), mimeType, size: stat.size };
},
+ // Search handler
+ 'search:query': async (_event, args) => {
+ return search(args.query, args.limit, args.types);
+ },
});
}
diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx
index 7658c4c8..6e79bec8 100644
--- a/apps/x/apps/renderer/src/App.tsx
+++ b/apps/x/apps/renderer/src/App.tsx
@@ -6,15 +6,15 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { Button } from './components/ui/button';
-import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon, SquarePen } from 'lucide-react';
+import { CheckIcon, LoaderIcon, PanelLeftIcon, Expand, Shrink, X, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor } from './components/markdown-editor';
-import { ChatInputBar } from './components/chat-button';
import { ChatSidebar } from './components/chat-sidebar';
+import { ChatInputWithMentions } from './components/chat-input-with-mentions';
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
import { useDebounce } from './hooks/use-debounce';
import { SidebarContentPanel } from '@/components/sidebar-content';
-import { SidebarSectionProvider, type ActiveSection } from '@/contexts/sidebar-context';
+import { SidebarSectionProvider } from '@/contexts/sidebar-context';
import {
Conversation,
ConversationContent,
@@ -28,9 +28,6 @@ import {
} from '@/components/ai-elements/message';
import {
type PromptInputMessage,
- PromptInputProvider,
- PromptInputTextarea,
- usePromptInputController,
type FileMention,
} from '@/components/ai-elements/prompt-input';
@@ -46,13 +43,30 @@ import {
SidebarProvider,
useSidebar,
} from "@/components/ui/sidebar"
-import { TooltipProvider } from "@/components/ui/tooltip"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Toaster } from "@/components/ui/sonner"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { OnboardingModal } from '@/components/onboarding-modal'
+import { SearchDialog } from '@/components/search-dialog'
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"
@@ -66,52 +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';
-
-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
@@ -138,41 +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 untitledBaseName = 'untitled'
const getHeadingTitle = (markdown: string) => {
@@ -218,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) => {
@@ -292,170 +202,6 @@ const collectDirPaths = (nodes: TreeNode[]): string[] =>
const collectFilePaths = (nodes: TreeNode[]): string[] =>
nodes.flatMap(n => n.kind === 'file' ? [n.path] : (n.children ? collectFilePaths(n.children) : []))
-// Inner component that uses the controller to access mentions
-interface ChatInputInnerProps {
- onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
- onStop?: () => void
- isProcessing: boolean
- isStopping?: boolean
- presetMessage?: string
- onPresetMessageConsumed?: () => void
- runId?: string | null
-}
-
-function ChatInputInner({
- onSubmit,
- onStop,
- isProcessing,
- isStopping,
- presetMessage,
- onPresetMessageConsumed,
- runId,
-}: ChatInputInnerProps) {
- const controller = usePromptInputController()
- const message = controller.textInput.value
- const canSubmit = Boolean(message.trim()) && !isProcessing
-
- // Handle preset message from suggestions
- useEffect(() => {
- if (presetMessage) {
- controller.textInput.setInput(presetMessage)
- onPresetMessageConsumed?.()
- }
- }, [presetMessage, controller.textInput, onPresetMessageConsumed])
-
- const handleSubmit = useCallback(() => {
- if (!canSubmit) return
- onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions)
- controller.textInput.clear()
- controller.mentions.clearMentions()
- }, [canSubmit, message, onSubmit, controller])
-
- const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault()
- handleSubmit()
- }
- }, [handleSubmit])
-
- useEffect(() => {
- const onDragOver = (e: DragEvent) => {
- if (e.dataTransfer?.types?.includes("Files")) {
- e.preventDefault()
- }
- }
- const onDrop = (e: DragEvent) => {
- if (e.dataTransfer?.types?.includes("Files")) {
- e.preventDefault()
- }
- if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
- const paths = Array.from(e.dataTransfer.files)
- .map((f) => window.electronUtils?.getPathForFile(f))
- .filter(Boolean)
- if (paths.length > 0) {
- const currentText = controller.textInput.value
- const pathText = paths.join(' ')
- controller.textInput.setInput(
- currentText ? `${currentText} ${pathText}` : pathText
- )
- }
- }
- }
- document.addEventListener("dragover", onDragOver)
- document.addEventListener("drop", onDrop)
- return () => {
- document.removeEventListener("dragover", onDragOver)
- document.removeEventListener("drop", onDrop)
- }
- }, [controller])
-
- return (
-
-
- {isProcessing ? (
-
- {isStopping ? (
-
- ) : (
-
- )}
-
- ) : (
-
-
-
- )}
-
- )
-}
-
-// Wrapper component with PromptInputProvider
-interface ChatInputWithMentionsProps {
- knowledgeFiles: string[]
- recentFiles: string[]
- visibleFiles: string[]
- onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
- onStop?: () => void
- isProcessing: boolean
- isStopping?: boolean
- presetMessage?: string
- onPresetMessageConsumed?: () => void
- runId?: string | null
-}
-
-function ChatInputWithMentions({
- knowledgeFiles,
- recentFiles,
- visibleFiles,
- onSubmit,
- onStop,
- isProcessing,
- isStopping,
- presetMessage,
- onPresetMessageConsumed,
- runId,
-}: ChatInputWithMentionsProps) {
- return (
-
-
-
- )
-}
-
/** A snapshot of which view the user is on */
type ViewState =
| { type: 'chat'; runId: string | null }
@@ -560,7 +306,7 @@ function ContentHeader({
return (
{!isCollapsed && onNavigateBack && onNavigateForward ? (
-
+
(null)
const [fileContent, setFileContent] = useState('')
const [editorContent, setEditorContent] = useState('')
const editorContentRef = useRef('')
+ const [editorContentByPath, setEditorContentByPath] = useState>({})
+ const editorContentByPathRef = useRef>(new Map())
const [tree, setTree] = useState([])
const [expandedPaths, setExpandedPaths] = useState>(new Set())
const [recentWikiFiles, setRecentWikiFiles] = useState([])
@@ -615,6 +365,8 @@ function App() {
const [graphStatus, setGraphStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
const [graphError, setGraphError] = useState(null)
const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true)
+ const [isRightPaneMaximized, setIsRightPaneMaximized] = useState(false)
+ const [activeShortcutPane, setActiveShortcutPane] = useState('left')
const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac')
const collapsedLeftPaddingPx =
(isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0) +
@@ -632,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)
@@ -641,7 +397,7 @@ function App() {
const renameInProgressRef = useRef(false)
// Chat state
- const [message, setMessage] = useState('')
+ const [, setMessage] = useState('')
const [conversation, setConversation] = useState([])
const [currentAssistantMessage, setCurrentAssistantMessage] = useState('')
const [, setModelUsage] = useState(null)
@@ -661,20 +417,148 @@ function App() {
type RunListItem = { id: string; title?: string; createdAt: string; agentId: string }
const [runs, setRuns] = useState([])
+ // Chat tab state
+ const [chatTabs, setChatTabs] = useState([{ id: 'default-chat-tab', runId: null }])
+ const [activeChatTabId, setActiveChatTabId] = useState('default-chat-tab')
+ const [chatViewStateByTab, setChatViewStateByTab] = useState>({
+ 'default-chat-tab': createEmptyChatTabViewState(),
+ })
+ const chatViewStateByTabRef = useRef(chatViewStateByTab)
+ const chatTabIdCounterRef = useRef(0)
+ const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}`
+ const chatDraftsRef = useRef(new Map())
+ const chatScrollTopByTabRef = useRef(new Map())
+ const [toolOpenByTab, setToolOpenByTab] = useState>>({})
+ const activeChatTabIdRef = useRef(activeChatTabId)
+ activeChatTabIdRef.current = activeChatTabId
+ const setChatDraftForTab = useCallback((tabId: string, text: string) => {
+ if (text) {
+ chatDraftsRef.current.set(tabId, text)
+ } else {
+ chatDraftsRef.current.delete(tabId)
+ }
+ }, [])
+ const isToolOpenForTab = useCallback((tabId: string, toolId: string): boolean => {
+ return toolOpenByTab[tabId]?.[toolId] ?? false
+ }, [toolOpenByTab])
+ const setToolOpenForTab = useCallback((tabId: string, toolId: string, open: boolean) => {
+ setToolOpenByTab((prev) => {
+ const prevForTab = prev[tabId] ?? {}
+ if (prevForTab[toolId] === open) return prev
+ return {
+ ...prev,
+ [tabId]: {
+ ...prevForTab,
+ [toolId]: open,
+ },
+ }
+ })
+ }, [])
+ const getChatScrollContainer = useCallback((tabId: string): HTMLElement | null => {
+ if (typeof document === 'undefined') return null
+ const panel = document.querySelector(
+ `[data-chat-tab-panel="${tabId}"][aria-hidden="false"]`
+ )
+ if (!panel) return null
+ const logRoot = panel.querySelector('[role="log"]')
+ if (!logRoot) return null
+ const children = Array.from(logRoot.children) as HTMLElement[]
+ for (const child of children) {
+ const style = window.getComputedStyle(child)
+ if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
+ return child
+ }
+ }
+ return null
+ }, [])
+ const saveChatScrollForTab = useCallback((tabId: string) => {
+ const container = getChatScrollContainer(tabId)
+ if (!container) return
+ chatScrollTopByTabRef.current.set(tabId, container.scrollTop)
+ }, [getChatScrollContainer])
+
+ const getChatTabTitle = useCallback((tab: ChatTab) => {
+ if (!tab.runId) return 'New chat'
+ return runs.find(r => r.id === tab.runId)?.title || '(Untitled chat)'
+ }, [runs])
+
+ const isChatTabProcessing = useCallback((tab: ChatTab) => {
+ return tab.runId ? processingRunIds.has(tab.runId) : false
+ }, [processingRunIds])
+
+ // File tab state
+ const [fileTabs, setFileTabs] = useState([])
+ const [activeFileTabId, setActiveFileTabId] = useState(null)
+ const fileTabIdCounterRef = useRef(0)
+ const newFileTabId = () => `file-tab-${++fileTabIdCounterRef.current}`
+
+ const getFileTabTitle = useCallback((tab: FileTab) => {
+ return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
+ }, [])
+
// Pending requests state
- const [pendingPermissionRequests, setPendingPermissionRequests] = useState>>(new Map())
+ const [, setPendingPermissionRequests] = useState>>(new Map())
const [pendingAskHumanRequests, setPendingAskHumanRequests] = useState>>(new Map())
// Track ALL permission requests (for rendering with response status)
const [allPermissionRequests, setAllPermissionRequests] = useState>>(new Map())
// Track permission responses (toolCallId -> response)
const [permissionResponses, setPermissionResponses] = useState>(new Map())
+ useEffect(() => {
+ chatViewStateByTabRef.current = chatViewStateByTab
+ }, [chatViewStateByTab])
+
+ useEffect(() => {
+ const snapshot: ChatTabViewState = {
+ runId,
+ conversation,
+ currentAssistantMessage,
+ pendingAskHumanRequests: new Map(pendingAskHumanRequests),
+ allPermissionRequests: new Map(allPermissionRequests),
+ permissionResponses: new Map(permissionResponses),
+ }
+ setChatViewStateByTab((prev) => ({ ...prev, [activeChatTabId]: snapshot }))
+ }, [
+ activeChatTabId,
+ runId,
+ conversation,
+ currentAssistantMessage,
+ pendingAskHumanRequests,
+ allPermissionRequests,
+ permissionResponses,
+ ])
+
+ useEffect(() => {
+ const tabIds = new Set(chatTabs.map((tab) => tab.id))
+ setChatViewStateByTab((prev) => {
+ let changed = false
+ const next: Record = {}
+ for (const [tabId, state] of Object.entries(prev)) {
+ if (tabIds.has(tabId)) {
+ next[tabId] = state
+ } else {
+ changed = true
+ }
+ }
+ for (const tabId of tabIds) {
+ if (!next[tabId]) {
+ next[tabId] = createEmptyChatTabViewState()
+ changed = true
+ }
+ }
+ return changed ? next : prev
+ })
+ }, [chatTabs])
+
// Workspace root for full paths
const [workspaceRoot, setWorkspaceRoot] = useState('')
// Onboarding state
const [showOnboarding, setShowOnboarding] = useState(false)
+ // Search state
+ const [isSearchOpen, setIsSearchOpen] = useState(false)
+
// Background tasks state
type BackgroundTaskItem = {
name: string
@@ -704,15 +588,37 @@ function App() {
runIdRef.current = runId
}, [runId])
- const handleEditorChange = useCallback((markdown: string) => {
+ const setEditorCacheForPath = useCallback((path: string, content: string) => {
+ editorContentByPathRef.current.set(path, content)
+ setEditorContentByPath((prev) => {
+ if (prev[path] === content) return prev
+ return { ...prev, [path]: content }
+ })
+ }, [])
+
+ const removeEditorCacheForPath = useCallback((path: string) => {
+ editorContentByPathRef.current.delete(path)
+ setEditorContentByPath((prev) => {
+ if (!(path in prev)) return prev
+ const next = { ...prev }
+ delete next[path]
+ return next
+ })
+ }, [])
+
+ const handleEditorChange = useCallback((path: string, markdown: string) => {
+ setEditorCacheForPath(path, markdown)
const nextSelectedPath = selectedPathRef.current
+ if (nextSelectedPath !== path) {
+ return
+ }
// Avoid clobbering editorPath during rapid transitions (e.g. autosave rename) where refs may lag a tick.
if (!editorPathRef.current || (nextSelectedPath && editorPathRef.current === nextSelectedPath)) {
editorPathRef.current = nextSelectedPath
}
editorContentRef.current = markdown
setEditorContent(markdown)
- }, [])
+ }, [setEditorCacheForPath])
// Keep processingRunIdsRef in sync for use in async callbacks
useEffect(() => {
processingRunIdsRef.current = processingRunIds
@@ -783,6 +689,7 @@ function App() {
if (selectedPathRef.current !== pathToReload) return
setFileContent(result.data)
setEditorContent(result.data)
+ setEditorCacheForPath(pathToReload, result.data)
editorContentRef.current = result.data
editorPathRef.current = pathToReload
initialContentByPathRef.current.set(pathToReload, result.data)
@@ -792,7 +699,7 @@ function App() {
})
return cleanup
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [loadDirectory, selectedPath, editorContent])
+ }, [loadDirectory, selectedPath, editorContent, setEditorCacheForPath])
// Load file content when selected
useEffect(() => {
@@ -804,6 +711,17 @@ function App() {
setLastSaved(null)
return
}
+ if (selectedPath.endsWith('.md')) {
+ const cachedContent = editorContentByPathRef.current.get(selectedPath)
+ if (cachedContent !== undefined) {
+ setFileContent(cachedContent)
+ setEditorContent(cachedContent)
+ editorContentRef.current = cachedContent
+ editorPathRef.current = selectedPath
+ initialContentRef.current = initialContentByPathRef.current.get(selectedPath) ?? cachedContent
+ return
+ }
+ }
const requestId = (fileLoadRequestIdRef.current += 1)
const pathToLoad = selectedPath
let cancelled = false
@@ -822,6 +740,9 @@ function App() {
&& normalizeForCompare(editorContentRef.current) !== normalizeForCompare(result.data)
if (!wouldClobberActiveEdits) {
setEditorContent(result.data)
+ if (pathToLoad.endsWith('.md')) {
+ setEditorCacheForPath(pathToLoad, result.data)
+ }
editorContentRef.current = result.data
editorPathRef.current = pathToLoad
initialContentByPathRef.current.set(pathToLoad, result.data)
@@ -850,7 +771,7 @@ function App() {
return () => {
cancelled = true
}
- }, [selectedPath])
+ }, [selectedPath, setEditorCacheForPath])
// Track recently opened markdown files for wiki links
useEffect(() => {
@@ -893,18 +814,32 @@ function App() {
const targetPath = `${parentDir}/${desiredName}.md`
if (targetPath !== pathAtStart) {
const exists = await window.ipc.invoke('workspace:exists', { path: targetPath })
- if (!exists.exists) {
- renameInProgressRef.current = true
- await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath })
- pathToSave = targetPath
- renamedFrom = pathAtStart
- renamedTo = targetPath
- editorPathRef.current = targetPath
- initialContentByPathRef.current.delete(pathAtStart)
- }
- }
- }
- }
+ if (!exists.exists) {
+ renameInProgressRef.current = true
+ await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath })
+ pathToSave = targetPath
+ renamedFrom = pathAtStart
+ renamedTo = targetPath
+ editorPathRef.current = targetPath
+ setFileTabs(prev => prev.map(tab => (tab.path === pathAtStart ? { ...tab, path: targetPath } : tab)))
+ initialContentByPathRef.current.delete(pathAtStart)
+ const cachedContent = editorContentByPathRef.current.get(pathAtStart)
+ if (cachedContent !== undefined) {
+ editorContentByPathRef.current.delete(pathAtStart)
+ editorContentByPathRef.current.set(targetPath, cachedContent)
+ setEditorContentByPath((prev) => {
+ const oldContent = prev[pathAtStart]
+ if (oldContent === undefined) return prev
+ const next = { ...prev }
+ delete next[pathAtStart]
+ next[targetPath] = oldContent
+ return next
+ })
+ }
+ }
+ }
+ }
+ }
await window.ipc.invoke('workspace:writeFile', {
path: pathToSave,
data: debouncedContent,
@@ -944,7 +879,7 @@ function App() {
}
}
saveFile()
- }, [debouncedContent])
+ }, [debouncedContent, setHistory])
// Load runs list (all pages)
const loadRuns = useCallback(async () => {
@@ -1177,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
@@ -1228,6 +1154,7 @@ function App() {
next.delete(event.runId)
return next
})
+ void loadRuns()
clearStreamingBuffer(event.runId)
if (!isActiveRun) return
setIsProcessing(false)
@@ -1273,6 +1200,16 @@ function App() {
case 'message':
{
const msg = event.message
+ if (msg.role === 'user' && typeof msg.content === 'string') {
+ const inferredTitle = inferRunTitleFromMessage(msg.content)
+ if (inferredTitle) {
+ setRuns(prev => prev.map(run => (
+ run.id === event.runId && run.title !== inferredTitle
+ ? { ...run, title: inferredTitle }
+ : run
+ )))
+ }
+ }
if (!isActiveRun) {
if (msg.role === 'assistant') {
clearStreamingBuffer(event.runId)
@@ -1467,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
@@ -1489,12 +1434,16 @@ function App() {
try {
let currentRunId = runId
let isNewRun = false
+ let newRunCreatedAt: string | null = null
if (!currentRunId) {
const run = await window.ipc.invoke('runs:create', {
agentId,
})
currentRunId = run.id
+ newRunCreatedAt = run.createdAt
setRunId(currentRunId)
+ // Update active chat tab's runId to the new run
+ setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: currentRunId } : t))
isNewRun = true
}
@@ -1526,9 +1475,17 @@ function App() {
message: formattedMessage,
})
- // Refresh runs list after message is sent (so title is available)
if (isNewRun) {
- loadRuns()
+ const inferredTitle = inferRunTitleFromMessage(formattedMessage)
+ setRuns(prev => {
+ const withoutCurrent = prev.filter(run => run.id !== currentRunId)
+ return [{
+ id: currentRunId!,
+ title: inferredTitle,
+ createdAt: newRunCreatedAt ?? new Date().toISOString(),
+ agentId,
+ }, ...withoutCurrent]
+ })
}
} catch (error) {
console.error('Failed to send message:', error)
@@ -1607,26 +1564,303 @@ function App() {
setAllPermissionRequests(new Map())
setPermissionResponses(new Map())
setSelectedBackgroundTask(null)
+ setChatViewStateByTab(prev => ({
+ ...prev,
+ [activeChatTabIdRef.current]: createEmptyChatTabViewState(),
+ }))
}, [])
- const handleChatInputSubmit = (text: string) => {
+ // Chat tab operations
+ const applyChatTab = useCallback((tab: ChatTab) => {
+ if (tab.runId) {
+ loadRun(tab.runId)
+ } else {
+ loadRunRequestIdRef.current += 1
+ setConversation([])
+ setCurrentAssistantMessage('')
+ setRunId(null)
+ setMessage('')
+ setModelUsage(null)
+ setIsProcessing(false)
+ setPendingPermissionRequests(new Map())
+ setPendingAskHumanRequests(new Map())
+ setAllPermissionRequests(new Map())
+ setPermissionResponses(new Map())
+ }
+ }, [loadRun])
+
+ const restoreChatTabState = useCallback((tabId: string, fallbackRunId: string | null): boolean => {
+ const cached = chatViewStateByTabRef.current[tabId]
+ if (!cached) return false
+
+ const resolvedRunId = cached.runId ?? fallbackRunId
+ setRunId(resolvedRunId)
+ setConversation(cached.conversation)
+ setCurrentAssistantMessage(cached.currentAssistantMessage)
+
+ const pendingPermissions = new Map>()
+ for (const [toolCallId, request] of cached.allPermissionRequests.entries()) {
+ if (!cached.permissionResponses.has(toolCallId)) {
+ pendingPermissions.set(toolCallId, request)
+ }
+ }
+ setPendingPermissionRequests(pendingPermissions)
+ setPendingAskHumanRequests(new Map(cached.pendingAskHumanRequests))
+ setAllPermissionRequests(new Map(cached.allPermissionRequests))
+ setPermissionResponses(new Map(cached.permissionResponses))
+ setIsProcessing(Boolean(resolvedRunId && processingRunIdsRef.current.has(resolvedRunId)))
+ return true
+ }, [])
+
+ const openChatInNewTab = useCallback((targetRunId: string) => {
+ const existingTab = chatTabs.find(t => t.runId === targetRunId)
+ if (existingTab) {
+ setActiveChatTabId(existingTab.id)
+ const restored = restoreChatTabState(existingTab.id, existingTab.runId)
+ if (processingRunIdsRef.current.has(targetRunId) || !restored) {
+ loadRun(targetRunId)
+ }
+ return
+ }
+ const id = newChatTabId()
+ setChatTabs(prev => [...prev, { id, runId: targetRunId }])
+ setActiveChatTabId(id)
+ loadRun(targetRunId)
+ }, [chatTabs, loadRun, restoreChatTabState])
+
+ const switchChatTab = useCallback((tabId: string) => {
+ const tab = chatTabs.find(t => t.id === tabId)
+ if (!tab) return
+ if (tabId === activeChatTabId) return
+ saveChatScrollForTab(activeChatTabId)
+ setActiveChatTabId(tabId)
+ const restored = restoreChatTabState(tabId, tab.runId)
+ if (tab.runId && processingRunIdsRef.current.has(tab.runId)) {
+ loadRun(tab.runId)
+ return
+ }
+ if (!restored) {
+ applyChatTab(tab)
+ }
+ }, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab])
+
+ const closeChatTab = useCallback((tabId: string) => {
+ if (chatTabs.length <= 1) return
+ const idx = chatTabs.findIndex(t => t.id === tabId)
+ if (idx === -1) return
+ saveChatScrollForTab(tabId)
+ const nextTabs = chatTabs.filter(t => t.id !== tabId)
+ setChatTabs(nextTabs)
+ setChatViewStateByTab(prev => {
+ if (!(tabId in prev)) return prev
+ const next = { ...prev }
+ delete next[tabId]
+ return next
+ })
+ chatDraftsRef.current.delete(tabId)
+ chatScrollTopByTabRef.current.delete(tabId)
+ setToolOpenByTab((prev) => {
+ if (!(tabId in prev)) return prev
+ const next = { ...prev }
+ delete next[tabId]
+ return next
+ })
+
+ if (tabId === activeChatTabId && nextTabs.length > 0) {
+ const newIdx = Math.min(idx, nextTabs.length - 1)
+ const newActiveTab = nextTabs[newIdx]
+ setActiveChatTabId(newActiveTab.id)
+ const restored = restoreChatTabState(newActiveTab.id, newActiveTab.runId)
+ if (newActiveTab.runId && processingRunIdsRef.current.has(newActiveTab.runId)) {
+ loadRun(newActiveTab.runId)
+ } else if (!restored) {
+ applyChatTab(newActiveTab)
+ }
+ }
+ }, [chatTabs, activeChatTabId, applyChatTab, loadRun, restoreChatTabState, saveChatScrollForTab])
+
+ useEffect(() => {
+ let cleanupScrollListener: (() => void) | undefined
+ let pollRaf: number | undefined
+ let restoreRafA: number | undefined
+ let restoreRafB: number | undefined
+ let restoreTimeout: ReturnType | undefined
+ let cancelled = false
+
+ const restoreScrollTop = (container: HTMLElement, top: number) => {
+ const maxScroll = Math.max(0, container.scrollHeight - container.clientHeight)
+ const clampedTop = clampNumber(top, 0, maxScroll)
+ container.scrollTop = clampedTop
+ }
+
+ const attach = (): boolean => {
+ if (cancelled) return true
+ const container = getChatScrollContainer(activeChatTabId)
+ if (!container) return false
+
+ const savedTop = chatScrollTopByTabRef.current.get(activeChatTabId)
+ if (savedTop !== undefined) {
+ // Reinforce restoration across a couple frames because stick-to-bottom
+ // may schedule scroll adjustments during mount/resize.
+ restoreScrollTop(container, savedTop)
+ restoreRafA = requestAnimationFrame(() => {
+ restoreScrollTop(container, savedTop)
+ restoreRafB = requestAnimationFrame(() => {
+ restoreScrollTop(container, savedTop)
+ })
+ })
+ restoreTimeout = setTimeout(() => {
+ restoreScrollTop(container, savedTop)
+ }, 220)
+ }
+
+ const onScroll = () => {
+ chatScrollTopByTabRef.current.set(activeChatTabId, container.scrollTop)
+ }
+ container.addEventListener('scroll', onScroll, { passive: true })
+ cleanupScrollListener = () => {
+ chatScrollTopByTabRef.current.set(activeChatTabId, container.scrollTop)
+ container.removeEventListener('scroll', onScroll)
+ }
+ return true
+ }
+
+ let attempts = 0
+ const maxAttempts = 60
+ const pollAttach = () => {
+ if (cancelled) return
+ if (attach()) return
+ if (attempts >= maxAttempts) return
+ attempts += 1
+ pollRaf = requestAnimationFrame(pollAttach)
+ }
+ pollAttach()
+
+ return () => {
+ cancelled = true
+ cleanupScrollListener?.()
+ if (pollRaf !== undefined) cancelAnimationFrame(pollRaf)
+ if (restoreRafA !== undefined) cancelAnimationFrame(restoreRafA)
+ if (restoreRafB !== undefined) cancelAnimationFrame(restoreRafB)
+ if (restoreTimeout !== undefined) clearTimeout(restoreTimeout)
+ }
+ }, [
+ activeChatTabId,
+ selectedPath,
+ isGraphOpen,
+ isChatSidebarOpen,
+ isRightPaneMaximized,
+ getChatScrollContainer,
+ ])
+
+ // File tab operations
+ const openFileInNewTab = useCallback((path: string) => {
+ const existingTab = fileTabs.find(t => t.path === path)
+ if (existingTab) {
+ setActiveFileTabId(existingTab.id)
+ setSelectedPath(path)
+ return
+ }
+ const id = newFileTabId()
+ setFileTabs(prev => [...prev, { id, path }])
+ setActiveFileTabId(id)
+ setSelectedPath(path)
+ }, [fileTabs])
+
+ const switchFileTab = useCallback((tabId: string) => {
+ const tab = fileTabs.find(t => t.id === tabId)
+ if (!tab) return
+ setActiveFileTabId(tabId)
+ setSelectedPath(tab.path)
+ }, [fileTabs])
+
+ const closeFileTab = useCallback((tabId: string) => {
+ const closingTab = fileTabs.find(t => t.id === tabId)
+ if (closingTab) {
+ removeEditorCacheForPath(closingTab.path)
+ initialContentByPathRef.current.delete(closingTab.path)
+ if (editorPathRef.current === closingTab.path) {
+ editorPathRef.current = null
+ }
+ }
+ setFileTabs(prev => {
+ if (prev.length <= 1) {
+ // Last file tab - close it and go back to chat
+ setActiveFileTabId(null)
+ setSelectedPath(null)
+ return []
+ }
+ const idx = prev.findIndex(t => t.id === tabId)
+ const next = prev.filter(t => t.id !== tabId)
+ if (tabId === activeFileTabId && next.length > 0) {
+ const newIdx = Math.min(idx, next.length - 1)
+ const newActiveTab = next[newIdx]
+ setActiveFileTabId(newActiveTab.id)
+ setSelectedPath(newActiveTab.path)
+ }
+ return next
+ })
+ }, [activeFileTabId, fileTabs, removeEditorCacheForPath])
+
+ const handleNewChatTab = useCallback(() => {
+ // If there's already an empty "New chat" tab, switch to it
+ const emptyTab = chatTabs.find(t => !t.runId)
+ if (emptyTab) {
+ if (emptyTab.id !== activeChatTabId) {
+ setActiveChatTabId(emptyTab.id)
+ }
+ } else {
+ // Create a new tab
+ const id = newChatTabId()
+ setChatTabs(prev => [...prev, { id, runId: null }])
+ setActiveChatTabId(id)
+ }
+ handleNewChat()
+ // Left-pane "new chat" should always open full chat view.
+ if (selectedPath || isGraphOpen) {
+ setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
+ } else {
+ setExpandedFrom(null)
+ }
+ setIsRightPaneMaximized(false)
+ setSelectedPath(null)
+ setIsGraphOpen(false)
+ }, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen])
+
+ // Sidebar variant: create/switch chat tab without leaving file/graph context.
+ const handleNewChatTabInSidebar = useCallback(() => {
+ const emptyTab = chatTabs.find(t => !t.runId)
+ if (emptyTab) {
+ if (emptyTab.id !== activeChatTabId) {
+ setActiveChatTabId(emptyTab.id)
+ }
+ } else {
+ const id = newChatTabId()
+ setChatTabs(prev => [...prev, { id, runId: null }])
+ setActiveChatTabId(id)
+ }
+ handleNewChat()
+ }, [chatTabs, activeChatTabId, handleNewChat])
+
+ const toggleKnowledgePane = useCallback(() => {
+ setIsRightPaneMaximized(false)
+ setIsChatSidebarOpen(prev => !prev)
+ }, [])
+
+ const toggleRightPaneMaximize = useCallback(() => {
setIsChatSidebarOpen(true)
- // Submit immediately - the sidebar will open and show the message
- handlePromptSubmit({ text, files: [] })
- }
+ setIsRightPaneMaximized(prev => !prev)
+ }, [])
const handleOpenFullScreenChat = useCallback(() => {
// Remember where we came from so the close button can return
if (selectedPath || isGraphOpen) {
setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
}
- // Copy sidebar input text to full-screen input (keep sidebar message intact for return)
- if (message.trim()) {
- setPresetMessage(message)
- }
+ setIsRightPaneMaximized(false)
setSelectedPath(null)
setIsGraphOpen(false)
- }, [selectedPath, isGraphOpen, message])
+ }, [selectedPath, isGraphOpen])
const handleCloseFullScreenChat = useCallback(() => {
if (expandedFrom) {
@@ -1636,14 +1870,10 @@ function App() {
setSelectedPath(expandedFrom.path)
}
setExpandedFrom(null)
+ setIsRightPaneMaximized(false)
}
}, [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 }
@@ -1663,6 +1893,8 @@ function App() {
setSelectedBackgroundTask(null)
setIsGraphOpen(false)
setExpandedFrom(null)
+ setIsChatSidebarOpen(true)
+ setIsRightPaneMaximized(false)
setSelectedPath(view.path)
return
case 'graph':
@@ -1670,17 +1902,20 @@ function App() {
setSelectedPath(null)
setExpandedFrom(null)
setIsGraphOpen(true)
+ setIsRightPaneMaximized(false)
return
case 'task':
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
+ setIsRightPaneMaximized(false)
setSelectedBackgroundTask(view.name)
return
case 'chat':
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
+ setIsRightPaneMaximized(false)
setSelectedBackgroundTask(null)
if (view.runId) {
await loadRun(view.runId)
@@ -1758,8 +1993,22 @@ function App() {
}, [viewHistory.forward, currentViewState])
const navigateToFile = useCallback((path: string) => {
+ // If already open in a file tab, switch to it
+ const existingTab = fileTabs.find(t => t.path === path)
+ if (existingTab) {
+ switchFileTab(existingTab.id)
+ return
+ }
+ // Update current file tab or create one if none exists
+ if (activeFileTabId) {
+ setFileTabs(prev => prev.map(t => t.id === activeFileTabId ? { ...t, path } : t))
+ } else {
+ const id = newFileTabId()
+ setFileTabs(prev => [...prev, { id, path }])
+ setActiveFileTabId(id)
+ }
void navigateToView({ type: 'file', path })
- }, [navigateToView])
+ }, [navigateToView, fileTabs, activeFileTabId, switchFileTab])
const navigateToFullScreenChat = useCallback(() => {
// Only treat this as navigation when coming from another view
@@ -1829,6 +2078,79 @@ function App() {
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat])
+ // Keyboard shortcut: Cmd+K / Ctrl+K to open search
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
+ e.preventDefault()
+ setIsSearchOpen(true)
+ }
+ }
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [])
+
+ // Keyboard shortcuts for tab management
+ useEffect(() => {
+ const handleTabKeyDown = (e: KeyboardEvent) => {
+ const mod = e.metaKey || e.ctrlKey
+ if (!mod) return
+ const rightPaneAvailable = Boolean((selectedPath || isGraphOpen) && isChatSidebarOpen)
+ const targetPane: ShortcutPane = rightPaneAvailable
+ ? (isRightPaneMaximized ? 'right' : activeShortcutPane)
+ : 'left'
+ const inFileView = targetPane === 'left' && Boolean(selectedPath)
+ const targetFileTabId = activeFileTabId ?? fileTabs.find((tab) => tab.path === selectedPath)?.id ?? null
+
+ // Cmd+W — close active tab
+ if (e.key === 'w') {
+ e.preventDefault()
+ if (inFileView && targetFileTabId) {
+ closeFileTab(targetFileTabId)
+ } else {
+ closeChatTab(activeChatTabId)
+ }
+ return
+ }
+
+ // Cmd+1..9 — switch to tab N (Cmd+9 always goes to last tab)
+ if (/^[1-9]$/.test(e.key)) {
+ e.preventDefault()
+ const n = parseInt(e.key, 10)
+ if (inFileView) {
+ const idx = e.key === '9' ? fileTabs.length - 1 : n - 1
+ const tab = fileTabs[idx]
+ if (tab) switchFileTab(tab.id)
+ } else {
+ const idx = e.key === '9' ? chatTabs.length - 1 : n - 1
+ const tab = chatTabs[idx]
+ if (tab) switchChatTab(tab.id)
+ }
+ return
+ }
+
+ // Cmd+Shift+] — next tab, Cmd+Shift+[ — previous tab
+ if (e.shiftKey && (e.key === ']' || e.key === '[')) {
+ e.preventDefault()
+ const direction = e.key === ']' ? 1 : -1
+ if (inFileView) {
+ const currentIdx = fileTabs.findIndex(t => t.id === targetFileTabId)
+ if (currentIdx === -1) return
+ const nextIdx = (currentIdx + direction + fileTabs.length) % fileTabs.length
+ switchFileTab(fileTabs[nextIdx].id)
+ } else {
+ const currentIdx = chatTabs.findIndex(t => t.id === activeChatTabId)
+ if (currentIdx === -1) return
+ const nextIdx = (currentIdx + direction + chatTabs.length) % chatTabs.length
+ switchChatTab(chatTabs[nextIdx].id)
+ }
+ return
+ }
+ }
+ document.addEventListener('keydown', handleTabKeyDown)
+ return () => document.removeEventListener('keydown', handleTabKeyDown)
+ }, [selectedPath, isGraphOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
+
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
if (kind === 'file') {
navigateToFile(path)
@@ -1844,16 +2166,6 @@ function App() {
setExpandedPaths(newExpanded)
}
- // Handle sidebar section changes - switch to chat view for tasks
- const handleSectionChange = useCallback((section: ActiveSection) => {
- if (section === 'tasks') {
- if (selectedBackgroundTask) return
- if (selectedPath || isGraphOpen) {
- void navigateToView({ type: 'chat', runId })
- }
- }
- }, [isGraphOpen, navigateToView, runId, selectedBackgroundTask, selectedPath])
-
// Knowledge quick actions
const knowledgeFiles = React.useMemo(() => {
const files = collectFilePaths(tree).filter((path) => path.endsWith('.md'))
@@ -1970,6 +2282,27 @@ function App() {
parts[parts.length - 1] = finalName
const newPath = parts.join('/')
await window.ipc.invoke('workspace:rename', { from: oldPath, to: newPath })
+ setFileTabs(prev => prev.map(tab => (tab.path === oldPath ? { ...tab, path: newPath } : tab)))
+ if (editorPathRef.current === oldPath) {
+ editorPathRef.current = newPath
+ }
+ const baseline = initialContentByPathRef.current.get(oldPath)
+ if (baseline !== undefined) {
+ initialContentByPathRef.current.delete(oldPath)
+ initialContentByPathRef.current.set(newPath, baseline)
+ }
+ const cachedContent = editorContentByPathRef.current.get(oldPath)
+ if (cachedContent !== undefined) {
+ editorContentByPathRef.current.delete(oldPath)
+ editorContentByPathRef.current.set(newPath, cachedContent)
+ setEditorContentByPath(prev => {
+ if (!(oldPath in prev)) return prev
+ const next = { ...prev }
+ delete next[oldPath]
+ next[newPath] = cachedContent
+ return next
+ })
+ }
if (selectedPath === oldPath) setSelectedPath(newPath)
} catch (err) {
console.error('Failed to rename:', err)
@@ -1979,7 +2312,17 @@ function App() {
remove: async (path: string) => {
try {
await window.ipc.invoke('workspace:remove', { path, opts: { trash: true } })
- if (selectedPath === path) setSelectedPath(null)
+ if (path.endsWith('.md')) {
+ removeEditorCacheForPath(path)
+ initialContentByPathRef.current.delete(path)
+ }
+ // Close any file tab showing the deleted file
+ const tabForFile = fileTabs.find(t => t.path === path)
+ if (tabForFile) {
+ closeFileTab(tabForFile.id)
+ } else if (selectedPath === path) {
+ setSelectedPath(null)
+ }
} catch (err) {
console.error('Failed to remove:', err)
throw err
@@ -1989,7 +2332,10 @@ function App() {
const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path
navigator.clipboard.writeText(fullPath)
},
- }), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView])
+ onOpenInNewTab: (path: string) => {
+ openFileInNewTab(path)
+ },
+ }), [tree, selectedPath, workspaceRoot, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab, removeEditorCacheForPath])
// Handler for when a voice note is created/updated
const handleVoiceNoteCreated = useCallback(async (notePath: string) => {
@@ -2164,7 +2510,7 @@ function App() {
}
}, [isGraphOpen, knowledgeFilePaths])
- const renderConversationItem = (item: ConversationItem) => {
+ const renderConversationItem = (item: ConversationItem, tabId: string) => {
if (isChatMessage(item)) {
if (item.role === 'user') {
const { message, files } = parseAttachedFiles(item.content)
@@ -2198,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 (
-
)
}
@@ -2235,7 +2560,11 @@ function App() {
const output = normalizeToolOutput(item.result, item.status)
const input = normalizeToolInput(item.input)
return (
-
+ setToolOpenForTab(tabId, item.id, open)}
+ >
0 || currentAssistantMessage
- const conversationContentClassName = hasConversation
- ? "mx-auto w-full max-w-4xl pb-28"
- : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
- const headerTitle = selectedPath
- ? selectedPath
- : isGraphOpen
- ? 'Graph View'
- : selectedBackgroundTask
- ? `Background Task: ${selectedBackgroundTask}`
- : 'Chat'
+ const activeChatTabState = React.useMemo(() => ({
+ runId,
+ conversation,
+ currentAssistantMessage,
+ pendingAskHumanRequests,
+ allPermissionRequests,
+ permissionResponses,
+ }), [
+ runId,
+ conversation,
+ currentAssistantMessage,
+ pendingAskHumanRequests,
+ allPermissionRequests,
+ permissionResponses,
+ ])
+ const emptyChatTabState = React.useMemo(() => createEmptyChatTabViewState(), [])
+ const getChatTabStateForRender = useCallback((tabId: string): ChatTabViewState => {
+ if (tabId === activeChatTabId) return activeChatTabState
+ return chatViewStateByTab[tabId] ?? emptyChatTabState
+ }, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState])
+ const hasConversation = activeChatTabState.conversation.length > 0 || activeChatTabState.currentAssistantMessage
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
+ const isRightPaneContext = Boolean(selectedPath || isGraphOpen)
+ const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
+ const openMarkdownTabs = React.useMemo(() => {
+ const markdownTabs = fileTabs.filter(tab => tab.path.endsWith('.md'))
+ if (selectedPath?.endsWith('.md')) {
+ const hasSelectedTab = markdownTabs.some(tab => tab.path === selectedPath)
+ if (!hasSelectedTab) {
+ return [...markdownTabs, { id: '__active-markdown-tab__', path: selectedPath }]
+ }
+ }
+ return markdownTabs
+ }, [fileTabs, selectedPath])
return (
-
-
+
+
{/* Content sidebar with SidebarProvider for collapse functionality */}
{
- void navigateToView({ type: 'chat', runId: null })
- },
+ onNewChat: handleNewChatTab,
onSelectRun: (runIdToLoad) => {
+ if (selectedPath || isGraphOpen) {
+ setIsChatSidebarOpen(true)
+ setIsRightPaneMaximized(false)
+ }
+
+ // If already open in a chat tab, switch to it
+ const existingTab = chatTabs.find(t => t.runId === runIdToLoad)
+ if (existingTab) {
+ switchChatTab(existingTab.id)
+ return
+ }
+ // In two-pane mode, keep current knowledge/graph context and just swap chat context.
+ if (selectedPath || isGraphOpen) {
+ setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
+ loadRun(runIdToLoad)
+ return
+ }
+
+ // Outside two-pane mode, navigate to chat.
+ setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
void navigateToView({ type: 'chat', runId: runIdToLoad })
},
+ onOpenInNewTab: (targetRunId) => {
+ openChatInNewTab(targetRunId)
+ },
onDeleteRun: async (runIdToDelete) => {
try {
await window.ipc.invoke('runs:delete', { runId: runIdToDelete })
- if (runId === runIdToDelete) {
- void navigateToView({ type: 'chat', runId: null })
+ // Close any chat tab showing the deleted run
+ const tabForRun = chatTabs.find(t => t.runId === runIdToDelete)
+ if (tabForRun) {
+ if (chatTabs.length > 1) {
+ closeChatTab(tabForRun.id)
+ } else {
+ // Only one tab, reset it to new chat
+ setChatTabs([{ id: tabForRun.id, runId: null }])
+ if (selectedPath || isGraphOpen) {
+ handleNewChat()
+ } else {
+ void navigateToView({ type: 'chat', runId: null })
+ }
+ }
+ } else if (runId === runIdToDelete) {
+ if (selectedPath || isGraphOpen) {
+ setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
+ handleNewChat()
+ } else {
+ void navigateToView({ type: 'chat', runId: null })
+ }
}
await loadRuns()
} catch (err) {
@@ -2324,7 +2715,12 @@ function App() {
backgroundTasks={backgroundTasks}
selectedBackgroundTask={selectedBackgroundTask}
/>
-
+ {!isRightPaneOnlyMode && (
+ setActiveShortcutPane('left')}
+ onFocusCapture={() => setActiveShortcutPane('left')}
+ >
{/* Header - also serves as titlebar drag region, adjusts padding when sidebar collapsed */}
{ void navigateBack() }}
@@ -2333,11 +2729,36 @@ function App() {
canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
>
-
- {headerTitle}
-
+ {selectedPath && fileTabs.length >= 1 ? (
+ t.id}
+ onSwitchTab={switchFileTab}
+ onCloseTab={closeFileTab}
+ />
+ ) : (
+ t.id}
+ isProcessing={isChatTabProcessing}
+ onSwitchTab={switchChatTab}
+ onCloseTab={closeChatTab}
+ />
+ )}
+ setIsSearchOpen(true)}
+ className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
+ aria-label="Search"
+ >
+
+
{selectedPath && (
-
+
{isSaving ? (
<>
@@ -2356,7 +2777,7 @@ function App() {
variant="ghost"
size="sm"
onClick={() => { void navigateToView({ type: 'chat', runId }) }}
- className="titlebar-no-drag text-foreground"
+ className="titlebar-no-drag text-foreground self-center shrink-0"
>
Close Graph
@@ -2365,21 +2786,32 @@ function App() {
)}
{(selectedPath || isGraphOpen) && (
-
setIsChatSidebarOpen(!isChatSidebarOpen)}
- className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors -mr-1"
- aria-label="Toggle Chat Sidebar"
- >
-
-
+
+
+
+ {isChatSidebarOpen ? : }
+
+
+
+ {isChatSidebarOpen
+ ? (selectedPath ? "Maximize knowledge view" : "Maximize main view")
+ : "Restore two-pane view"}
+
+
)}
@@ -2398,13 +2830,32 @@ function App() {
) : selectedPath ? (
selectedPath.endsWith('.md') ? (
-
+ {openMarkdownTabs.map((tab) => {
+ const isActive = activeFileTabId
+ ? tab.id === activeFileTabId || tab.path === selectedPath
+ : tab.path === selectedPath
+ const tabContent = editorContentByPath[tab.path]
+ ?? (isActive && editorPathRef.current === tab.path ? editorContent : '')
+ return (
+
+ handleEditorChange(tab.path, markdown)}
+ placeholder="Start writing..."
+ wikiLinks={wikiLinkConfig}
+ onImageUpload={handleImageUpload}
+ />
+
+ )
+ })}
) : (
@@ -2431,70 +2882,92 @@ function App() {
) : (
{ navigateToFile(path) }}>
-
-
-
- {!hasConversation ? (
-
-
- What are we working on?
-
-
- ) : (
- <>
- {conversation.map(item => {
- const rendered = renderConversationItem(item)
- // If this is a tool call, check for permission request (pending or responded)
- if (isToolCall(item)) {
- const permRequest = allPermissionRequests.get(item.id)
- if (permRequest) {
- const response = permissionResponses.get(item.id) || null
- return (
-
- {rendered}
- handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
- onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
- isProcessing={isProcessing}
- response={response}
+
+ {chatTabs.map((tab) => {
+ const isActive = tab.id === activeChatTabId
+ const tabState = getChatTabStateForRender(tab.id)
+ const tabHasConversation = tabState.conversation.length > 0 || tabState.currentAssistantMessage
+ const tabConversationContentClassName = tabHasConversation
+ ? "mx-auto w-full max-w-4xl pb-28"
+ : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
+ return (
+
+
+
+
+ {!tabHasConversation ? (
+
+
+ What are we working on?
+
+
+ ) : (
+ <>
+ {tabState.conversation.map(item => {
+ const rendered = renderConversationItem(item, tab.id)
+ if (isToolCall(item)) {
+ const permRequest = tabState.allPermissionRequests.get(item.id)
+ if (permRequest) {
+ const response = tabState.permissionResponses.get(item.id) || null
+ return (
+
+ {rendered}
+ handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
+ onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
+ isProcessing={isActive && isProcessing}
+ response={response}
+ />
+
+ )
+ }
+ }
+ return rendered
+ })}
+
+ {Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
+ handleAskHumanResponse(request.toolCallId, request.subflow, response)}
+ isProcessing={isActive && isProcessing}
/>
-
- )
- }
- }
- return rendered
- })}
+ ))}
- {/* Render pending ask-human requests */}
- {Array.from(pendingAskHumanRequests.values()).map((request) => (
- handleAskHumanResponse(request.toolCallId, request.subflow, response)}
- isProcessing={isProcessing}
- />
- ))}
+ {tabState.currentAssistantMessage && (
+
+
+ {tabState.currentAssistantMessage}
+
+
+ )}
- {currentAssistantMessage && (
-
-
- {currentAssistantMessage}
-
-
- )}
-
- {isProcessing && !currentAssistantMessage && (
-
-
- Thinking...
-
-
- )}
- >
- )}
-
-
+ {isActive && isProcessing && !tabState.currentAssistantMessage && (
+
+
+ Thinking...
+
+
+ )}
+ >
+ )}
+
+
+
+ )
+ })}
+
@@ -2502,51 +2975,80 @@ function App() {
{!hasConversation && (
)}
-
setPresetMessage(undefined)}
- runId={runId}
- />
+ {chatTabs.map((tab) => {
+ const isActive = tab.id === activeChatTabId
+ const tabState = getChatTabStateForRender(tab.id)
+ return (
+
+ setPresetMessage(undefined) : undefined}
+ runId={tabState.runId}
+ initialDraft={chatDraftsRef.current.get(tab.id)}
+ onDraftChange={(text) => setChatDraftForTab(tab.id, text)}
+ />
+
+ )
+ })}
)}
+ )}
{/* Chat sidebar - shown when viewing files/graph */}
- {(selectedPath || isGraphOpen) && (
+ {isRightPaneContext && (
setPresetMessage(undefined)}
+ getInitialDraft={(tabId) => chatDraftsRef.current.get(tabId)}
+ onDraftChangeForTab={setChatDraftForTab}
pendingAskHumanRequests={pendingAskHumanRequests}
allPermissionRequests={allPermissionRequests}
permissionResponses={permissionResponses}
onPermissionResponse={handlePermissionResponse}
onAskHumanResponse={handleAskHumanResponse}
+ isToolOpenForTab={isToolOpenForTab}
+ onToolOpenChangeForTab={setToolOpenForTab}
onOpenKnowledgeFile={(path) => { navigateToFile(path) }}
+ onActivate={() => setActiveShortcutPane('right')}
/>
)}
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
@@ -2555,19 +3057,17 @@ function App() {
onNavigateForward={() => { void navigateForward() }}
canNavigateBack={canNavigateBack}
canNavigateForward={canNavigateForward}
- onNewChat={handleNewChat}
+ onNewChat={handleNewChatTab}
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
/>
-
- {/* Floating chat input - shown when viewing files/graph and chat sidebar is closed */}
- {(selectedPath || isGraphOpen) && !isChatSidebarOpen && (
- setIsChatSidebarOpen(true)}
- />
- )}
+
{ void navigateToView({ type: 'chat', runId: id }) }}
+ />
{
- const { isAtBottom } = useStickToBottomContext();
+ const { isAtBottom, scrollRef } = useStickToBottomContext();
const preservationContext = useContext(ScrollPreservationContext);
const containerFoundRef = useRef(false);
@@ -110,29 +110,13 @@ export const ScrollPositionPreserver = () => {
useLayoutEffect(() => {
if (containerFoundRef.current || !preservationContext) return;
- // Find the scroll container (StickToBottom creates one)
- // It's the first parent with overflow-y scroll/auto
- const findScrollContainer = (): HTMLElement | null => {
- const candidates = document.querySelectorAll('[role="log"]');
- for (const candidate of candidates) {
- // The scroll container is a direct child of the role="log" element
- const children = candidate.children;
- for (const child of children) {
- const style = window.getComputedStyle(child);
- if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
- return child as HTMLElement;
- }
- }
- }
- return null;
- };
-
- const container = findScrollContainer();
+ // Use the local StickToBottom scroll container for this conversation instance.
+ const container = scrollRef.current;
if (container) {
preservationContext.registerScrollContainer(container);
containerFoundRef.current = true;
}
- }, [preservationContext]);
+ }, [preservationContext, scrollRef]);
// Track engagement based on scroll position
useEffect(() => {
diff --git a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx
index c27ab5c3..98263434 100644
--- a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx
+++ b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx
@@ -931,7 +931,13 @@ export const PromptInputTextarea = ({
if (autoFocus || focusTrigger !== undefined) {
// Small delay to ensure the element is fully mounted and visible
const timer = setTimeout(() => {
- textareaRef.current?.focus();
+ const textarea = textareaRef.current;
+ if (!textarea) return;
+ try {
+ textarea.focus({ preventScroll: true });
+ } catch {
+ textarea.focus();
+ }
}, 50);
return () => clearTimeout(timer);
}
diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
new file mode 100644
index 00000000..31bcba17
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
@@ -0,0 +1,201 @@
+import { useCallback, useEffect } from 'react'
+import { ArrowUp, LoaderIcon, Square } from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import {
+ type FileMention,
+ type PromptInputMessage,
+ PromptInputProvider,
+ PromptInputTextarea,
+ usePromptInputController,
+} from '@/components/ai-elements/prompt-input'
+
+interface ChatInputInnerProps {
+ onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
+ onStop?: () => void
+ isProcessing: boolean
+ isStopping?: boolean
+ isActive: boolean
+ presetMessage?: string
+ onPresetMessageConsumed?: () => void
+ runId?: string | null
+ initialDraft?: string
+ onDraftChange?: (text: string) => void
+}
+
+function ChatInputInner({
+ onSubmit,
+ onStop,
+ isProcessing,
+ isStopping,
+ isActive,
+ presetMessage,
+ onPresetMessageConsumed,
+ runId,
+ initialDraft,
+ onDraftChange,
+}: ChatInputInnerProps) {
+ const controller = usePromptInputController()
+ const message = controller.textInput.value
+ const canSubmit = Boolean(message.trim()) && !isProcessing
+
+ // Restore the tab draft when this input mounts.
+ useEffect(() => {
+ if (initialDraft) {
+ controller.textInput.setInput(initialDraft)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ useEffect(() => {
+ onDraftChange?.(message)
+ }, [message, onDraftChange])
+
+ useEffect(() => {
+ if (presetMessage) {
+ controller.textInput.setInput(presetMessage)
+ onPresetMessageConsumed?.()
+ }
+ }, [presetMessage, controller.textInput, onPresetMessageConsumed])
+
+ const handleSubmit = useCallback(() => {
+ if (!canSubmit) return
+ onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions)
+ controller.textInput.clear()
+ controller.mentions.clearMentions()
+ }, [canSubmit, message, onSubmit, controller])
+
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSubmit()
+ }
+ }, [handleSubmit])
+
+ useEffect(() => {
+ if (!isActive) return
+ const onDragOver = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes('Files')) {
+ e.preventDefault()
+ }
+ }
+
+ const onDrop = (e: DragEvent) => {
+ if (e.dataTransfer?.types?.includes('Files')) {
+ e.preventDefault()
+ }
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
+ const paths = Array.from(e.dataTransfer.files)
+ .map((file) => window.electronUtils?.getPathForFile(file))
+ .filter(Boolean)
+ if (paths.length > 0) {
+ const currentText = controller.textInput.value
+ const pathText = paths.join(' ')
+ controller.textInput.setInput(currentText ? `${currentText} ${pathText}` : pathText)
+ }
+ }
+ }
+
+ document.addEventListener('dragover', onDragOver)
+ document.addEventListener('drop', onDrop)
+ return () => {
+ document.removeEventListener('dragover', onDragOver)
+ document.removeEventListener('drop', onDrop)
+ }
+ }, [controller, isActive])
+
+ return (
+
+
+ {isProcessing ? (
+
+ {isStopping ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+ )
+}
+
+export interface ChatInputWithMentionsProps {
+ knowledgeFiles: string[]
+ recentFiles: string[]
+ visibleFiles: string[]
+ onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
+ onStop?: () => void
+ isProcessing: boolean
+ isStopping?: boolean
+ isActive?: boolean
+ presetMessage?: string
+ onPresetMessageConsumed?: () => void
+ runId?: string | null
+ initialDraft?: string
+ onDraftChange?: (text: string) => void
+}
+
+export function ChatInputWithMentions({
+ knowledgeFiles,
+ recentFiles,
+ visibleFiles,
+ onSubmit,
+ onStop,
+ isProcessing,
+ isStopping,
+ isActive = true,
+ presetMessage,
+ onPresetMessageConsumed,
+ runId,
+ initialDraft,
+ onDraftChange,
+}: ChatInputWithMentionsProps) {
+ return (
+
+
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
index 8d2a005f..44fdbafd 100644
--- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx
+++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx
@@ -1,13 +1,9 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { ArrowUp, Expand, LoaderIcon, SquarePen, Square } from 'lucide-react'
-import type { ToolUIPart } from 'ai'
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Expand, Shrink, SquarePen } from 'lucide-react'
+
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from '@/components/ui/tooltip'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
Conversation,
ConversationContent,
@@ -19,233 +15,193 @@ import {
MessageContent,
MessageResponse,
} 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 { useMentionDetection } from '@/hooks/use-mention-detection'
-import { MentionPopover } from '@/components/mention-popover'
-import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
-import { getMentionHighlightSegments } from '@/lib/mention-highlights'
-import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js'
-import z from 'zod'
-import React from 'react'
import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
-
-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'
-
-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 { TabBar, type ChatTab } from '@/components/tab-bar'
+import { ChatInputWithMentions } from '@/components/chat-input-with-mentions'
+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 }
-const MIN_WIDTH = 300
-const MAX_WIDTH = 700
-const DEFAULT_WIDTH = 400
+const MIN_WIDTH = 360
+const MAX_WIDTH = 1600
+const MIN_MAIN_PANE_WIDTH = 420
+const MIN_MAIN_PANE_RATIO = 0.3
+const DEFAULT_WIDTH = 460
+const RIGHT_PANE_WIDTH_STORAGE_KEY = 'x:right-pane-width'
+
+function clampPaneWidth(width: number, maxWidth: number = MAX_WIDTH): number {
+ const boundedMax = Math.max(0, Math.min(MAX_WIDTH, maxWidth))
+ const boundedMin = Math.min(MIN_WIDTH, boundedMax)
+ return Math.min(boundedMax, Math.max(boundedMin, width))
+}
+
+function getInitialPaneWidth(defaultWidth: number): number {
+ const fallback = clampPaneWidth(defaultWidth)
+ if (typeof window === 'undefined') return fallback
+ try {
+ const raw = window.localStorage.getItem(RIGHT_PANE_WIDTH_STORAGE_KEY)
+ if (!raw) return fallback
+ const parsed = Number(raw)
+ if (!Number.isFinite(parsed)) return fallback
+ return clampPaneWidth(parsed)
+ } catch {
+ return fallback
+ }
+}
interface ChatSidebarProps {
defaultWidth?: number
isOpen?: boolean
- onNewChat: () => void
+ isMaximized?: boolean
+ chatTabs: ChatTab[]
+ activeChatTabId: string
+ getChatTabTitle: (tab: ChatTab) => string
+ isChatTabProcessing: (tab: ChatTab) => boolean
+ onSwitchChatTab: (tabId: string) => void
+ onCloseChatTab: (tabId: string) => void
+ onNewChatTab: () => void
onOpenFullScreen?: () => void
conversation: ConversationItem[]
currentAssistantMessage: string
+ chatTabStates?: Record
isProcessing: boolean
isStopping?: boolean
onStop?: () => void
- message: string
- onMessageChange: (message: string) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
knowledgeFiles?: string[]
recentFiles?: string[]
visibleFiles?: string[]
- selectedPath?: string | null
- pendingPermissionRequests?: Map>
- pendingAskHumanRequests?: Map>
- allPermissionRequests?: Map>
- permissionResponses?: Map
- onPermissionResponse?: (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => void
+ runId?: string | null
+ presetMessage?: string
+ onPresetMessageConsumed?: () => void
+ getInitialDraft?: (tabId: string) => string | undefined
+ onDraftChangeForTab?: (tabId: string, text: string) => 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
onOpenKnowledgeFile?: (path: string) => void
+ onActivate?: () => void
}
export function ChatSidebar({
defaultWidth = DEFAULT_WIDTH,
isOpen = true,
- onNewChat,
+ isMaximized = false,
+ chatTabs,
+ activeChatTabId,
+ getChatTabTitle,
+ isChatTabProcessing,
+ onSwitchChatTab,
+ onCloseChatTab,
+ onNewChatTab,
onOpenFullScreen,
conversation,
currentAssistantMessage,
+ chatTabStates = {},
isProcessing,
isStopping,
onStop,
- message,
- onMessageChange,
onSubmit,
knowledgeFiles = [],
recentFiles = [],
visibleFiles = [],
- selectedPath,
+ runId,
+ presetMessage,
+ onPresetMessageConsumed,
+ getInitialDraft,
+ onDraftChangeForTab,
pendingAskHumanRequests = new Map(),
allPermissionRequests = new Map(),
permissionResponses = new Map(),
onPermissionResponse,
onAskHumanResponse,
+ isToolOpenForTab,
+ onToolOpenChangeForTab,
onOpenKnowledgeFile,
+ onActivate,
}: ChatSidebarProps) {
- const [width, setWidth] = useState(defaultWidth)
+ const [width, setWidth] = useState(() => getInitialPaneWidth(defaultWidth))
const [isResizing, setIsResizing] = useState(false)
const [showContent, setShowContent] = useState(isOpen)
+ const [localPresetMessage, setLocalPresetMessage] = useState(undefined)
+
+ const paneRef = useRef(null)
+ const startXRef = useRef(0)
+ const startWidthRef = useRef(0)
+
+ const getMaxAllowedWidth = useCallback(() => {
+ if (typeof window === 'undefined') return MAX_WIDTH
+ const paneElement = paneRef.current
+ const splitContainer = paneElement?.parentElement
+ const mainPane = splitContainer?.querySelector('[data-slot="sidebar-inset"]')
+ const paneWidth = paneElement?.getBoundingClientRect().width ?? 0
+ const mainPaneWidth = mainPane?.getBoundingClientRect().width ?? 0
+ const splitWidth = paneWidth + mainPaneWidth
+ const fallbackWidth = splitContainer?.clientWidth ?? window.innerWidth
+ const availableSplitWidth = splitWidth > 0 ? splitWidth : fallbackWidth
+ const minMainPaneWidth = Math.min(
+ availableSplitWidth,
+ Math.max(
+ MIN_MAIN_PANE_WIDTH,
+ Math.floor(availableSplitWidth * MIN_MAIN_PANE_RATIO)
+ )
+ )
+ return Math.max(0, availableSplitWidth - minMainPaneWidth)
+ }, [])
- // Delay showing content when opening, hide immediately when closing
useEffect(() => {
if (isOpen) {
const timer = setTimeout(() => setShowContent(true), 150)
return () => clearTimeout(timer)
- } else {
- setShowContent(false)
}
+ setShowContent(false)
}, [isOpen])
- const startXRef = useRef(0)
- const startWidthRef = useRef(0)
- const textareaRef = useRef(null)
- const containerRef = useRef(null)
- const highlightRef = useRef(null)
- const [mentions, setMentions] = useState([])
- const autoMentionRef = useRef<{ path: string; displayName: string } | null>(null)
- const lastSelectedPathRef = useRef(null)
-
- // Build mention labels for highlighting (handles multi-word names like "AI Agents")
- const mentionLabels = useMemo(() => {
- if (knowledgeFiles.length === 0) return []
- const labels = knowledgeFiles
- .map((path) => wikiLabel(path))
- .map((label) => label.trim())
- .filter(Boolean)
- return Array.from(new Set(labels))
- }, [knowledgeFiles])
-
- const { activeMention, cursorCoords } = useMentionDetection(
- textareaRef,
- message,
- knowledgeFiles.length > 0
- )
-
- // Use proper regex-based highlight segmentation that handles multi-word names
- const mentionHighlights = useMemo(
- () => getMentionHighlightSegments(message, activeMention, mentionLabels),
- [message, activeMention, mentionLabels]
- )
-
- // Sync highlight overlay scroll with textarea
- const syncHighlightScroll = useCallback(() => {
- const textarea = textareaRef.current
- const highlight = highlightRef.current
- if (!textarea || !highlight) return
- highlight.scrollTop = textarea.scrollTop
- highlight.scrollLeft = textarea.scrollLeft
- }, [])
useEffect(() => {
- syncHighlightScroll()
- }, [message, mentionHighlights.hasHighlights, syncHighlightScroll])
+ if (typeof window === 'undefined') return
+ try {
+ window.localStorage.setItem(RIGHT_PANE_WIDTH_STORAGE_KEY, String(width))
+ } catch {
+ // Ignore persistence failures and keep in-memory behavior.
+ }
+ }, [width])
- const handleMentionSelect = useCallback(
- (path: string, displayName: string) => {
- if (!activeMention) return
+ useEffect(() => {
+ const clampToAvailableWidth = () => {
+ const maxAllowedWidth = getMaxAllowedWidth()
+ setWidth((prev) => clampPaneWidth(prev, maxAllowedWidth))
+ }
- const beforeAt = message.substring(0, activeMention.triggerIndex)
- const afterQuery = message.substring(
- activeMention.triggerIndex + 1 + activeMention.query.length
- )
-
- const newText = `${beforeAt}@${displayName} ${afterQuery}`
- onMessageChange(newText)
-
- const fullPath = toKnowledgePath(path)
- if (fullPath) {
- setMentions(prev => {
- if (prev.some(m => m.path === fullPath)) return prev
- return [...prev, { id: `mention-${Date.now()}`, path: fullPath, displayName }]
- })
- }
-
- textareaRef.current?.focus()
- },
- [activeMention, message, onMessageChange]
- )
-
- const handleMentionClose = useCallback(() => {
- // The popover handles its own closing
- }, [])
+ clampToAvailableWidth()
+ window.addEventListener('resize', clampToAvailableWidth)
+ return () => window.removeEventListener('resize', clampToAvailableWidth)
+ }, [getMaxAllowedWidth])
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
@@ -253,10 +209,10 @@ export function ChatSidebar({
startWidthRef.current = width
setIsResizing(true)
- const handleMouseMove = (e: MouseEvent) => {
- const delta = startXRef.current - e.clientX
- const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidthRef.current + delta))
- setWidth(newWidth)
+ const handleMouseMove = (event: MouseEvent) => {
+ const delta = startXRef.current - event.clientX
+ const maxAllowedWidth = getMaxAllowedWidth()
+ setWidth(clampPaneWidth(startWidthRef.current + delta, maxAllowedWidth))
}
const handleMouseUp = () => {
@@ -267,159 +223,89 @@ export function ChatSidebar({
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
- }, [width])
+ }, [width, getMaxAllowedWidth])
- // Auto-focus textarea when sidebar opens or when conversation is cleared (new chat)
- useEffect(() => {
- // Focus when conversation is empty (new chat started)
- if (conversation.length === 0) {
- const timer = setTimeout(() => {
- textareaRef.current?.focus()
- }, 50)
- return () => clearTimeout(timer)
- }
- }, [conversation.length])
+ const activeTabState = useMemo(() => ({
+ runId: runId ?? null,
+ conversation,
+ currentAssistantMessage,
+ pendingAskHumanRequests,
+ allPermissionRequests,
+ permissionResponses,
+ }), [
+ runId,
+ conversation,
+ currentAssistantMessage,
+ pendingAskHumanRequests,
+ allPermissionRequests,
+ permissionResponses,
+ ])
+ const emptyTabState = useMemo(() => createEmptyChatTabViewState(), [])
+ const getTabState = useCallback((tabId: string): ChatTabViewState => {
+ if (tabId === activeChatTabId) return activeTabState
+ return chatTabStates[tabId] ?? emptyTabState
+ }, [activeChatTabId, activeTabState, chatTabStates, emptyTabState])
+ const hasConversation = activeTabState.conversation.length > 0 || Boolean(activeTabState.currentAssistantMessage)
- // Auto-populate with @currentfile when switching knowledge files
- useEffect(() => {
- if (selectedPath === lastSelectedPathRef.current) return
- lastSelectedPathRef.current = selectedPath ?? null
-
- if (!selectedPath || !selectedPath.startsWith('knowledge/') || !selectedPath.endsWith('.md')) {
- return
- }
-
- const displayName = wikiLabel(selectedPath)
- const previousAuto = autoMentionRef.current
- const trimmed = message.trim()
- const previousToken = previousAuto ? `@${previousAuto.displayName}` : null
- const shouldReplace = !trimmed || (previousToken && trimmed === previousToken)
-
- if (!shouldReplace) {
- return
- }
-
- const nextText = `@${displayName} `
- if (message !== nextText) {
- onMessageChange(nextText)
- }
-
- setMentions((prev) => {
- const withoutPrevious = previousAuto
- ? prev.filter((mention) => mention.path !== previousAuto.path)
- : prev
- if (withoutPrevious.some((mention) => mention.path === selectedPath)) {
- return withoutPrevious
- }
- return [
- ...withoutPrevious,
- {
- id: `mention-auto-${Date.now()}`,
- path: selectedPath,
- displayName,
- },
- ]
- })
-
- autoMentionRef.current = { path: selectedPath, displayName }
- }, [selectedPath, message, onMessageChange])
-
- const hasConversation = conversation.length > 0 || currentAssistantMessage
- const canSubmit = Boolean(message.trim()) && !isProcessing
-
- const handleSubmit = () => {
- const trimmed = message.trim()
- if (trimmed && !isProcessing) {
- onSubmit({ text: trimmed, files: [] }, mentions)
- setMentions([])
- }
- }
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- // If mention popover is open, let it handle navigation keys
- if (activeMention && ['ArrowDown', 'ArrowUp', 'Tab', 'Escape'].includes(e.key)) {
- return
- }
-
- if (e.key === 'Enter') {
- // If mention popover is open, Enter should select the item
- if (activeMention) {
- return
- }
-
- if (!e.shiftKey) {
- e.preventDefault()
- handleSubmit()
- }
- }
-
- // Handle backspace to delete entire mention at once
- if (e.key === 'Backspace') {
- const textarea = e.currentTarget
- const cursorPos = textarea.selectionStart
- const selectionEnd = textarea.selectionEnd
-
- // Only handle if no text is selected (cursor is at a single position)
- if (cursorPos !== selectionEnd) return
-
- // Check if cursor is right after a mention
- for (const label of mentionLabels) {
- const mentionText = `@${label}`
- const startPos = cursorPos - mentionText.length
- if (startPos >= 0) {
- const textBefore = message.substring(startPos, cursorPos)
- if (textBefore === mentionText) {
- // Check if it's at word boundary (start of string or preceded by whitespace)
- if (startPos === 0 || /\s/.test(message[startPos - 1])) {
- e.preventDefault()
- const newText = message.substring(0, startPos) + message.substring(cursorPos)
- onMessageChange(newText)
- // Remove the mention from state
- setMentions(prev => prev.filter(m => m.displayName !== label))
- // Set cursor position after React updates
- setTimeout(() => {
- textarea.selectionStart = startPos
- textarea.selectionEnd = startPos
- }, 0)
- return
- }
- }
- }
- }
- }
- }
-
- const renderConversationItem = (item: ConversationItem) => {
+ 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)
return (
-
-
+ onToolOpenChangeForTab?.(tabId, item.id, open)}
+ >
+
- {output !== null ? (
-
- ) : null}
+ {output !== null ? : null}
)
@@ -438,218 +324,211 @@ export function ChatSidebar({
return null
}
- const displayWidth = isOpen ? width : 0
+ const paneStyle = useMemo(() => {
+ if (!isOpen) {
+ return { width: 0, flex: '0 0 auto' }
+ }
+ if (isMaximized) {
+ // In maximize mode the pane should grow into the freed left space,
+ // not add extra width to the right and overflow the app viewport.
+ return { width: 0, flex: '1 1 auto' }
+ }
+ return { width, flex: '0 0 auto' }
+ }, [isOpen, isMaximized, width])
return (
- {/* Resize handle */}
-
+ {!isMaximized && (
+
+ )}
- {/* Content - delayed on open, hidden immediately on close to avoid layout issues during animation */}
{showContent && (
<>
- {/* Header - minimal, expand and new chat buttons */}
-
+
+ tab.id}
+ isProcessing={isChatTabProcessing}
+ onSwitchTab={onSwitchChatTab}
+ onCloseTab={onCloseChatTab}
+ />
-
-
+
+
- New chat
+ New chat tab
{onOpenFullScreen && (
-
-
+
+ {isMaximized ? : }
- Full screen chat
+
+ {isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
+
)}
- {/* Conversation area */}
- {})}>
-
-
-
-
- {!hasConversation ? (
-
-
-
- ) : (
- <>
- {conversation.map(item => {
- const rendered = renderConversationItem(item)
- // If this is a tool call, check for permission request (pending or responded)
- if (isToolCall(item) && onPermissionResponse) {
- const permRequest = allPermissionRequests.get(item.id)
- if (permRequest) {
- const response = permissionResponses.get(item.id) || null
- return (
-
- {rendered}
- onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
- onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
- isProcessing={isProcessing}
- response={response}
- />
-
- )
- }
- }
- return rendered
+ {})}>
+
+
+ {chatTabs.map((tab) => {
+ const isActive = tab.id === activeChatTabId
+ const tabState = getTabState(tab.id)
+ const tabHasConversation = tabState.conversation.length > 0 || Boolean(tabState.currentAssistantMessage)
+ return (
+
+
+
+
+ {!tabHasConversation ? (
+
+ Ask anything...
+
+ ) : (
+ <>
+ {tabState.conversation.map((item) => {
+ const rendered = renderConversationItem(item, tab.id)
+ if (isToolCall(item) && onPermissionResponse) {
+ const permRequest = tabState.allPermissionRequests.get(item.id)
+ if (permRequest) {
+ const response = tabState.permissionResponses.get(item.id) || null
+ return (
+
+ {rendered}
+ onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
+ onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
+ isProcessing={isActive && isProcessing}
+ response={response}
+ />
+
+ )
+ }
+ }
+ return rendered
+ })}
+
+ {onAskHumanResponse && Array.from(tabState.pendingAskHumanRequests.values()).map((request) => (
+ onAskHumanResponse(request.toolCallId, request.subflow, response)}
+ isProcessing={isActive && isProcessing}
+ />
+ ))}
+
+ {tabState.currentAssistantMessage && (
+
+
+ {tabState.currentAssistantMessage}
+
+
+ )}
+
+ {isActive && isProcessing && !tabState.currentAssistantMessage && (
+
+
+ Thinking...
+
+
+ )}
+ >
+ )}
+
+
+
+ )
})}
+
- {/* Render pending ask-human requests */}
- {onAskHumanResponse && Array.from(pendingAskHumanRequests.values()).map((request) => (
-
onAskHumanResponse(request.toolCallId, request.subflow, response)}
- isProcessing={isProcessing}
- />
- ))}
-
- {currentAssistantMessage && (
-
-
- {currentAssistantMessage}
-
-
- )}
-
- {isProcessing && !currentAssistantMessage && (
-
-
- Thinking...
-
-
- )}
- >
- )}
-
-
-
- {/* Input area - responsive to sidebar width, matches floating bar position exactly */}
-
- {!hasConversation && (
-
{
- onMessageChange(prompt)
- setTimeout(() => textareaRef.current?.focus(), 0)
- }}
- vertical
- className="mb-3"
- />
- )}
-
-
- {mentionHighlights.hasHighlights && (
-
- {mentionHighlights.segments.map((segment, index) =>
- segment.highlighted ? (
-
- {segment.text}
-
- ) : (
-
{segment.text}
- )
+
+
+
+ {!hasConversation && (
+
)}
+ {chatTabs.map((tab) => {
+ const isActive = tab.id === activeChatTabId
+ const tabState = getTabState(tab.id)
+ return (
+
+ {
+ setLocalPresetMessage(undefined)
+ onPresetMessageConsumed?.()
+ } : undefined}
+ runId={tabState.runId}
+ initialDraft={getInitialDraft?.(tab.id)}
+ onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
+ />
+
+ )
+ })}
- )}
-
- {isProcessing ? (
-
- {isStopping ? (
-
- ) : (
-
- )}
-
- ) : (
-
-
-
- )}
-
- {knowledgeFiles.length > 0 && (
-
- )}
-
-
-
+
>
)}
diff --git a/apps/x/apps/renderer/src/components/search-dialog.tsx b/apps/x/apps/renderer/src/components/search-dialog.tsx
new file mode 100644
index 00000000..22e398fe
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/search-dialog.tsx
@@ -0,0 +1,208 @@
+import { useState, useEffect, useCallback } from 'react'
+import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
+import {
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+} from '@/components/ui/command'
+import { useDebounce } from '@/hooks/use-debounce'
+import { useSidebarSection, type ActiveSection } from '@/contexts/sidebar-context'
+import { cn } from '@/lib/utils'
+
+interface SearchResult {
+ type: 'knowledge' | 'chat'
+ title: string
+ preview: string
+ path: string
+}
+
+type SearchType = 'knowledge' | 'chat'
+
+function activeTabToTypes(section: ActiveSection): SearchType[] {
+ if (section === 'knowledge') return ['knowledge']
+ return ['chat'] // "tasks" tab maps to chat
+}
+
+interface SearchDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSelectFile: (path: string) => void
+ onSelectRun: (runId: string) => void
+}
+
+export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) {
+ const { activeSection } = useSidebarSection()
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [isSearching, setIsSearching] = useState(false)
+ const [activeTypes, setActiveTypes] = useState>(
+ () => new Set(activeTabToTypes(activeSection))
+ )
+ const debouncedQuery = useDebounce(query, 250)
+
+ // Sync filter preselection when dialog opens
+ useEffect(() => {
+ if (open) {
+ setActiveTypes(new Set(activeTabToTypes(activeSection)))
+ }
+ }, [open, activeSection])
+
+ const toggleType = useCallback((type: SearchType) => {
+ setActiveTypes(new Set([type]))
+ }, [])
+
+ useEffect(() => {
+ if (!debouncedQuery.trim()) {
+ setResults([])
+ return
+ }
+
+ let cancelled = false
+ setIsSearching(true)
+
+ const types = Array.from(activeTypes) as ('knowledge' | 'chat')[]
+ window.ipc.invoke('search:query', { query: debouncedQuery, limit: 20, types })
+ .then((res) => {
+ if (!cancelled) {
+ setResults(res.results)
+ }
+ })
+ .catch((err) => {
+ console.error('Search failed:', err)
+ if (!cancelled) {
+ setResults([])
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setIsSearching(false)
+ }
+ })
+
+ return () => { cancelled = true }
+ }, [debouncedQuery, activeTypes])
+
+ // Reset state when dialog closes
+ useEffect(() => {
+ if (!open) {
+ setQuery('')
+ setResults([])
+ }
+ }, [open])
+
+ const handleSelect = useCallback((result: SearchResult) => {
+ onOpenChange(false)
+ if (result.type === 'knowledge') {
+ onSelectFile(result.path)
+ } else {
+ onSelectRun(result.path)
+ }
+ }, [onOpenChange, onSelectFile, onSelectRun])
+
+ const knowledgeResults = results.filter(r => r.type === 'knowledge')
+ const chatResults = results.filter(r => r.type === 'chat')
+
+ return (
+
+
+
+ toggleType('knowledge')}
+ icon={ }
+ label="Knowledge"
+ />
+ toggleType('chat')}
+ icon={ }
+ label="Chats"
+ />
+
+
+ {!query.trim() && (
+ Type to search...
+ )}
+ {query.trim() && !isSearching && results.length === 0 && (
+ No results found.
+ )}
+ {knowledgeResults.length > 0 && (
+
+ {knowledgeResults.map((result) => (
+ handleSelect(result)}
+ >
+
+
+ {result.title}
+ {result.preview}
+
+
+ ))}
+
+ )}
+ {chatResults.length > 0 && (
+
+ {chatResults.map((result) => (
+ handleSelect(result)}
+ >
+
+
+ {result.title}
+ {result.preview}
+
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+function FilterToggle({
+ active,
+ onClick,
+ icon,
+ label,
+}: {
+ active: boolean
+ onClick: () => void
+ icon: React.ReactNode
+ label: string
+}) {
+ return (
+
+ {icon}
+ {label}
+
+ )
+}
diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx
index 5906f4d5..6a4bec64 100644
--- a/apps/x/apps/renderer/src/components/sidebar-content.tsx
+++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx
@@ -8,6 +8,7 @@ import {
ChevronsDownUp,
ChevronsUpDown,
Copy,
+ ExternalLink,
FilePlus,
FolderPlus,
AlertTriangle,
@@ -105,6 +106,7 @@ type KnowledgeActions = {
rename: (path: string, newName: string, isDir: boolean) => Promise
remove: (path: string) => Promise
copyPath: (path: string) => void
+ onOpenInNewTab?: (path: string) => void
}
type RunListItem = {
@@ -149,6 +151,7 @@ type TasksActions = {
onNewChat: () => void
onSelectRun: (runId: string) => void
onDeleteRun: (runId: string) => void
+ onOpenInNewTab?: (runId: string) => void
onSelectBackgroundTask?: (taskName: string) => void
}
@@ -981,6 +984,15 @@ function Tree({
>
)}
+ {!isDir && actions.onOpenInNewTab && (
+ <>
+ actions.onOpenInNewTab!(item.path)}>
+
+ Open in new tab
+
+
+ >
+ )}
Copy Path
@@ -1033,12 +1045,20 @@ function Tree({
return (
-
+
onSelect(item.path, item.kind)}
+ onClick={(e) => {
+ if (e.metaKey && actions.onOpenInNewTab) {
+ actions.onOpenInNewTab(item.path)
+ } else {
+ onSelect(item.path, item.kind)
+ }
+ }}
>
- {item.name}
+
+ {item.name}
+
@@ -1162,37 +1182,54 @@ function TasksSection({
{runs.map((run) => (
-
- actions?.onSelectRun(run.id)}
- >
-
- {processingRunIds?.has(run.id) ? (
-
- ) : null}
-
{run.title || '(Untitled chat)'}
- {run.createdAt ? (
-
- {formatRunTime(run.createdAt)}
-
- ) : null}
- {!processingRunIds?.has(run.id) && (
-
{
- e.stopPropagation()
- setPendingDeleteRunId(run.id)
- }}
- aria-label="Delete chat"
+
+
+
+ {
+ if (e.metaKey && actions?.onOpenInNewTab) {
+ actions.onOpenInNewTab(run.id)
+ } else {
+ actions?.onSelectRun(run.id)
+ }
+ }}
+ >
+
+ {processingRunIds?.has(run.id) ? (
+
+ ) : null}
+ {run.title || '(Untitled chat)'}
+ {run.createdAt ? (
+
+ {formatRunTime(run.createdAt)}
+
+ ) : null}
+
+
+
+
+
+ {actions?.onOpenInNewTab && (
+ actions.onOpenInNewTab!(run.id)}>
+
+ Open in new tab
+
+ )}
+ {!processingRunIds?.has(run.id) && (
+ <>
+ {actions?.onOpenInNewTab && }
+ setPendingDeleteRunId(run.id)}
>
-
-
- )}
-
-
-
+
+ Delete
+
+ >
+ )}
+
+
))}
>
diff --git a/apps/x/apps/renderer/src/components/tab-bar.tsx b/apps/x/apps/renderer/src/components/tab-bar.tsx
new file mode 100644
index 00000000..4abe8175
--- /dev/null
+++ b/apps/x/apps/renderer/src/components/tab-bar.tsx
@@ -0,0 +1,95 @@
+import * as React from "react"
+import { X } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+export type ChatTab = {
+ id: string
+ runId: string | null
+}
+
+export type FileTab = {
+ id: string
+ path: string
+}
+
+interface TabBarProps {
+ tabs: T[]
+ activeTabId: string
+ getTabTitle: (tab: T) => string
+ getTabId: (tab: T) => string
+ isProcessing?: (tab: T) => boolean
+ onSwitchTab: (tabId: string) => void
+ onCloseTab: (tabId: string) => void
+ layout?: 'fill' | 'scroll'
+}
+
+export function TabBar({
+ tabs,
+ activeTabId,
+ getTabTitle,
+ getTabId,
+ isProcessing,
+ onSwitchTab,
+ onCloseTab,
+ layout = 'fill',
+}: TabBarProps) {
+ return (
+
+ {tabs.map((tab, index) => {
+ const tabId = getTabId(tab)
+ const isActive = tabId === activeTabId
+ const processing = isProcessing?.(tab) ?? false
+ const title = getTabTitle(tab)
+
+ return (
+
+ {index > 0 && (
+
+ )}
+ onSwitchTab(tabId)}
+ className={cn(
+ 'titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
+ layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]',
+ isActive
+ ? 'bg-background text-foreground'
+ : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
+ )}
+ style={layout === 'scroll' ? { flex: '0 0 auto' } : { flex: '1 1 0px' }}
+ >
+ {processing && (
+
+ )}
+ {title}
+ {tabs.length > 1 && (
+ {
+ e.stopPropagation()
+ onCloseTab(tabId)
+ }}
+ aria-label="Close tab"
+ >
+
+
+ )}
+
+ {/* Right edge divider after last tab to close off the section */}
+ {index === tabs.length - 1 && (
+
+ )}
+
+ )
+ })}
+
+ )
+}
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
+}
diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css
index d94f0ffa..31ce2bf1 100644
--- a/apps/x/apps/renderer/src/styles/editor.css
+++ b/apps/x/apps/renderer/src/styles/editor.css
@@ -16,8 +16,17 @@
position: relative;
}
+/* Notion-like base typography */
.tiptap-editor .ProseMirror {
- padding: 1rem;
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif,
+ "Segoe UI Emoji", "Segoe UI Symbol";
+ font-size: 16px;
+ line-height: 1.5;
+ color: rgb(55, 53, 47);
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 2rem 4rem;
outline: none;
}
@@ -27,47 +36,45 @@
/* Placeholder */
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
- color: var(--muted-foreground);
+ color: rgba(55, 53, 47, 0.4);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
-/* Typography */
-.tiptap-editor .ProseMirror {
- font-size: 1rem;
- line-height: 1.75;
- color: var(--foreground);
-}
-
-.tiptap-editor .ProseMirror > * + * {
- margin-top: 0.75em;
+/* Paragraphs */
+.tiptap-editor .ProseMirror p {
+ margin: 1px 0;
+ padding: 3px 2px;
}
/* Headings */
.tiptap-editor .ProseMirror h1 {
- font-size: 2em;
- font-weight: 700;
- line-height: 1.2;
- margin-top: 1.5em;
- margin-bottom: 0.5em;
+ font-size: 1.875em;
+ font-weight: 600;
+ line-height: 1.3;
+ margin-top: 2em;
+ margin-bottom: 4px;
+ padding: 3px 2px;
}
.tiptap-editor .ProseMirror h2 {
font-size: 1.5em;
font-weight: 600;
line-height: 1.3;
- margin-top: 1.25em;
- margin-bottom: 0.5em;
+ margin-top: 1.1em;
+ margin-bottom: 1px;
+ padding: 3px 2px;
}
.tiptap-editor .ProseMirror h3 {
font-size: 1.25em;
font-weight: 600;
- line-height: 1.4;
+ line-height: 1.3;
margin-top: 1em;
- margin-bottom: 0.5em;
+ margin-bottom: 1px;
+ padding: 3px 2px;
}
.tiptap-editor .ProseMirror h1:first-child,
@@ -76,16 +83,11 @@
margin-top: 0;
}
-/* Paragraphs */
-.tiptap-editor .ProseMirror p {
- margin: 0;
-}
-
/* Lists */
.tiptap-editor .ProseMirror ul,
.tiptap-editor .ProseMirror ol {
- padding-left: 1.5em;
- margin: 0.5em 0;
+ padding-left: 1.625em;
+ margin: 1px 0;
}
.tiptap-editor .ProseMirror ul {
@@ -97,7 +99,7 @@
}
.tiptap-editor .ProseMirror li {
- margin: 0.25em 0;
+ padding: 3px 0;
}
.tiptap-editor .ProseMirror li p {
@@ -106,50 +108,56 @@
/* Blockquote */
.tiptap-editor .ProseMirror blockquote {
- border-left: 3px solid var(--border);
- padding-left: 1em;
+ border-left: 3px solid rgb(55, 53, 47);
+ padding-left: 14px;
+ margin: 4px 0;
margin-left: 0;
margin-right: 0;
- color: var(--muted-foreground);
- font-style: italic;
}
-/* Code */
-.tiptap-editor .ProseMirror code {
- background-color: var(--muted);
- border-radius: 0.25em;
- padding: 0.15em 0.3em;
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
- font-size: 0.9em;
-}
-
-/* Code Block */
+/* Code blocks */
.tiptap-editor .ProseMirror pre {
- background-color: var(--muted);
- border-radius: 0.5em;
- padding: 1em;
+ background: rgb(247, 246, 243);
+ border-radius: 4px;
+ padding: 2rem;
+ font-family: "SFMono-Regular", Menlo, Consolas, "PT Mono",
+ "Liberation Mono", Courier, monospace;
+ font-size: 0.85em;
+ line-height: 1.5;
+ margin: 8px 0;
overflow-x: auto;
- margin: 0.75em 0;
}
.tiptap-editor .ProseMirror pre code {
background: none;
padding: 0;
- font-size: 0.875em;
+ font-size: inherit;
color: inherit;
+ border-radius: 0;
}
-/* Horizontal Rule */
+/* Inline code */
+.tiptap-editor .ProseMirror code {
+ background: rgba(135, 131, 120, 0.15);
+ border-radius: 3px;
+ padding: 0.2em 0.4em;
+ font-family: "SFMono-Regular", Menlo, Consolas, monospace;
+ font-size: 0.85em;
+ color: #eb5757;
+}
+
+/* Divider */
.tiptap-editor .ProseMirror hr {
border: none;
- border-top: 1px solid var(--border);
- margin: 1.5em 0;
+ border-top: 1px solid rgba(55, 53, 47, 0.16);
+ margin: 8px 0;
}
/* Links */
.tiptap-editor .ProseMirror a {
- color: var(--primary);
+ color: inherit;
text-decoration: underline;
+ text-decoration-color: rgba(55, 53, 47, 0.4);
text-underline-offset: 2px;
cursor: pointer;
}
@@ -175,14 +183,13 @@
.tiptap-editor .ProseMirror ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
- margin: 0.5em 0;
+ margin: 1px 0;
}
.tiptap-editor .ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.5em;
- margin: 0.25em 0;
}
.tiptap-editor .ProseMirror ul[data-type="taskList"] li > label {
@@ -238,14 +245,16 @@
align-self: center;
}
-/* Content area centering */
+/* Keep knowledge text width readable while margins collapse on narrow panes. */
.tiptap-editor .ProseMirror {
- margin-left: 20%;
- margin-right: 20%;
- padding-left: 1rem;
- padding-right: 1rem;
+ width: 100%;
+ max-width: min(56rem, calc(100% - clamp(0.5rem, 2.5vw, 2rem)));
+ margin-left: auto;
+ margin-right: auto;
+ box-sizing: border-box;
+ padding-left: clamp(0.5rem, 1.5vw, 1rem);
+ padding-right: clamp(0.5rem, 1.5vw, 1rem);
}
-
.wiki-link-anchor {
position: absolute;
height: 0;
@@ -327,3 +336,33 @@
background-color: var(--primary);
transition: width 0.3s ease;
}
+
+/* Dark mode overrides */
+.dark .tiptap-editor .ProseMirror {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.dark .tiptap-editor .ProseMirror a {
+ text-decoration-color: rgba(255, 255, 255, 0.4);
+}
+
+.dark .tiptap-editor .ProseMirror blockquote {
+ border-left-color: rgba(255, 255, 255, 0.4);
+}
+
+.dark .tiptap-editor .ProseMirror hr {
+ border-top-color: rgba(255, 255, 255, 0.16);
+}
+
+.dark .tiptap-editor .ProseMirror pre {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.dark .tiptap-editor .ProseMirror code {
+ background: rgba(255, 255, 255, 0.1);
+ color: #ff7b72;
+}
+
+.dark .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
+ color: rgba(255, 255, 255, 0.3);
+}
diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts
index 09d1c721..4012e9b3 100644
--- a/apps/x/packages/core/src/agents/runtime.ts
+++ b/apps/x/packages/core/src/agents/runtime.ts
@@ -226,13 +226,14 @@ export class StreamStepMessageBuilder {
private textBuffer: string = "";
private reasoningBuffer: string = "";
private providerOptions: z.infer | undefined = undefined;
+ private reasoningProviderOptions: z.infer | undefined = undefined;
flushBuffers() {
- // skip reasoning
- // if (this.reasoningBuffer) {
- // this.parts.push({ type: "reasoning", text: this.reasoningBuffer });
- // this.reasoningBuffer = "";
- // }
+ if (this.reasoningBuffer || this.reasoningProviderOptions) {
+ this.parts.push({ type: "reasoning", text: this.reasoningBuffer, providerOptions: this.reasoningProviderOptions });
+ this.reasoningBuffer = "";
+ this.reasoningProviderOptions = undefined;
+ }
if (this.textBuffer) {
this.parts.push({ type: "text", text: this.textBuffer });
this.textBuffer = "";
@@ -242,7 +243,11 @@ export class StreamStepMessageBuilder {
ingest(event: z.infer) {
switch (event.type) {
case "reasoning-start":
+ break;
case "reasoning-end":
+ this.reasoningProviderOptions = event.providerOptions;
+ this.flushBuffers();
+ break;
case "text-start":
case "text-end":
this.flushBuffers();
@@ -827,10 +832,6 @@ export async function* streamAgent({
tools,
signal,
)) {
- // Only log significant events (not text-delta to reduce noise)
- if (event.type !== 'text-delta') {
- loopLogger.log('got llm-stream-event:', event.type);
- }
messageBuilder.ingest(event);
yield* processEvent({
runId,
@@ -924,9 +925,11 @@ async function* streamLlm(
tools: ToolSet,
signal?: AbortSignal,
): AsyncGenerator, void, unknown> {
+ const converted = convertFromMessages(messages);
+ console.log(`! SENDING payload to model: `, JSON.stringify(converted))
const { fullStream } = streamText({
model,
- messages: convertFromMessages(messages),
+ messages: converted,
system: instructions,
tools,
stopWhen: stepCountIs(1),
@@ -935,7 +938,7 @@ async function* streamLlm(
for await (const event of fullStream) {
// Check abort on every chunk for responsiveness
signal?.throwIfAborted();
- // console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event));
+ console.log("-> \t\tstream event", JSON.stringify(event));
switch (event.type) {
case "error":
yield {
@@ -968,6 +971,12 @@ async function* streamLlm(
providerOptions: event.providerMetadata,
};
break;
+ case "text-end":
+ yield {
+ type: "text-end",
+ providerOptions: event.providerMetadata,
+ };
+ break;
case "text-delta":
yield {
type: "text-delta",
diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts
index 5e0ce472..c0365c0f 100644
--- a/apps/x/packages/core/src/application/assistant/instructions.ts
+++ b/apps/x/packages/core/src/application/assistant/instructions.ts
@@ -125,6 +125,18 @@ Always consult this catalog first so you load the right skills before taking act
- Summarize completed work and suggest logical next steps at the end of a task.
- Always ask for confirmation before taking destructive actions.
+## Output Formatting
+- Use **H3** (###) for section headers in longer responses. Never use H1 or H2 — they're too large for chat.
+- Use **bold** for key terms, names, or concepts the user should notice.
+- Keep bullet points short (1-2 lines each). Use them for lists of 3+ items, not for general prose.
+- Use numbered lists only when order matters (steps, rankings).
+- For short answers (1-3 sentences), just use plain prose. No headers, no bullets.
+- Use code blocks with language tags (\`\`\`python, \`\`\`json, etc.) for any code or config.
+- Use inline \`code\` for file names, commands, variable names, or short technical references.
+- Add a blank line between sections for breathing room.
+- Never start a response with a heading. Lead with a sentence or two of context first.
+- Avoid deeply nested bullets. If nesting beyond 2 levels, restructure.
+
## MCP Tool Discovery (CRITICAL)
**ALWAYS check for MCP tools BEFORE saying you can't do something.**
diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts
index 97d1e8b2..070b4642 100644
--- a/apps/x/packages/core/src/composio/client.ts
+++ b/apps/x/packages/core/src/composio/client.ts
@@ -343,7 +343,7 @@ export async function executeAction(
try {
const client = getComposioClient();
const result = await client.tools.execute(actionSlug, {
- userId: connectedAccountId,
+ userId: 'rowboat-user',
arguments: input,
connectedAccountId,
dangerouslySkipVersionCheck: true,
@@ -352,8 +352,8 @@ export async function executeAction(
console.log(`[Composio] Action completed successfully`);
return { success: true, data: result.data };
} catch (error) {
- console.error(`[Composio] Action execution failed:`, error);
- const message = error instanceof Error ? error.message : 'Unknown error';
+ console.error(`[Composio] Action execution failed:`, JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}), 2));
+ const message = error instanceof Error ? error.message : (typeof error === 'object' ? JSON.stringify(error) : 'Unknown error');
return { success: false, data: null, error: message };
}
}
diff --git a/apps/x/packages/core/src/search/search.ts b/apps/x/packages/core/src/search/search.ts
new file mode 100644
index 00000000..d68449f5
--- /dev/null
+++ b/apps/x/packages/core/src/search/search.ts
@@ -0,0 +1,375 @@
+import path from 'path';
+import fs from 'fs';
+import fsp from 'fs/promises';
+import readline from 'readline';
+import { execFile } from 'child_process';
+import { WorkDir } from '../config/config.js';
+
+interface SearchResult {
+ type: 'knowledge' | 'chat';
+ title: string;
+ preview: string;
+ path: string;
+}
+
+const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
+const RUNS_DIR = path.join(WorkDir, 'runs');
+
+type SearchType = 'knowledge' | 'chat';
+
+/**
+ * Search across knowledge files and chat history.
+ * @param types - optional filter to search only specific types (default: both)
+ */
+export async function search(query: string, limit = 20, types?: SearchType[]): Promise<{ results: SearchResult[] }> {
+ const trimmed = query.trim();
+ if (!trimmed) {
+ return { results: [] };
+ }
+
+ const searchKnowledgeEnabled = !types || types.includes('knowledge');
+ const searchChatsEnabled = !types || types.includes('chat');
+
+ const [knowledgeResults, chatResults] = await Promise.all([
+ searchKnowledgeEnabled ? searchKnowledge(trimmed, limit) : Promise.resolve([]),
+ searchChatsEnabled ? searchChats(trimmed, limit) : Promise.resolve([]),
+ ]);
+
+ const results = [...knowledgeResults, ...chatResults].slice(0, limit);
+ return { results };
+}
+
+/**
+ * Search knowledge markdown files by content and filename.
+ */
+async function searchKnowledge(query: string, limit: number): Promise {
+ if (!fs.existsSync(KNOWLEDGE_DIR)) {
+ return [];
+ }
+
+ const results: SearchResult[] = [];
+ const seenPaths = new Set();
+ const lowerQuery = query.toLowerCase();
+
+ // Content search via grep
+ try {
+ const grepMatches = await grepFiles(query, KNOWLEDGE_DIR, '*.md');
+ for (const match of grepMatches) {
+ if (results.length >= limit) break;
+ const relPath = path.relative(WorkDir, match.file);
+ if (seenPaths.has(relPath)) continue;
+ seenPaths.add(relPath);
+
+ const title = path.basename(match.file, '.md');
+ results.push({
+ type: 'knowledge',
+ title,
+ preview: match.line.trim().substring(0, 150),
+ path: relPath,
+ });
+ }
+ } catch {
+ // grep failed (no matches or dir issue) — continue
+ }
+
+ // Filename search — check files whose name matches the query
+ try {
+ const allFiles = await listMarkdownFiles(KNOWLEDGE_DIR);
+ for (const file of allFiles) {
+ if (results.length >= limit) break;
+ const relPath = path.relative(WorkDir, file);
+ if (seenPaths.has(relPath)) continue;
+
+ const basename = path.basename(file, '.md');
+ if (basename.toLowerCase().includes(lowerQuery)) {
+ seenPaths.add(relPath);
+ const preview = await readFirstLines(file, 2);
+ results.push({
+ type: 'knowledge',
+ title: basename,
+ preview,
+ path: relPath,
+ });
+ }
+ }
+ } catch {
+ // ignore errors
+ }
+
+ return results;
+}
+
+/**
+ * Search chat history by title and message content.
+ */
+async function searchChats(query: string, limit: number): Promise {
+ if (!fs.existsSync(RUNS_DIR)) {
+ return [];
+ }
+
+ const results: SearchResult[] = [];
+ const seenIds = new Set();
+ const lowerQuery = query.toLowerCase();
+
+ // Content search via grep on JSONL files
+ try {
+ const grepMatches = await grepFiles(query, RUNS_DIR, '*.jsonl');
+ for (const match of grepMatches) {
+ if (results.length >= limit) break;
+ const runId = path.basename(match.file, '.jsonl');
+ if (seenIds.has(runId)) continue;
+
+ const meta = await readRunMetadata(match.file);
+ if (meta.agentName !== 'copilot') {
+ seenIds.add(runId);
+ continue;
+ }
+ seenIds.add(runId);
+
+ // Extract a content preview from the matching line
+ let preview = '';
+ try {
+ const parsed = JSON.parse(match.line);
+ if (parsed.message?.content && typeof parsed.message.content === 'string') {
+ preview = parsed.message.content.replace(/[\s\S]*?<\/attached-files>/g, '').trim().substring(0, 150);
+ }
+ } catch {
+ preview = match.line.substring(0, 150);
+ }
+
+ results.push({
+ type: 'chat',
+ title: meta.title || runId,
+ preview,
+ path: runId,
+ });
+ }
+ } catch {
+ // grep failed — continue
+ }
+
+ // Title search — scan run files for matching titles
+ try {
+ const entries = await fsp.readdir(RUNS_DIR, { withFileTypes: true });
+ const jsonlFiles = entries
+ .filter(e => e.isFile() && e.name.endsWith('.jsonl'))
+ .map(e => e.name)
+ .sort()
+ .reverse(); // newest first
+
+ for (const name of jsonlFiles) {
+ if (results.length >= limit) break;
+ const runId = path.basename(name, '.jsonl');
+ if (seenIds.has(runId)) continue;
+
+ const filePath = path.join(RUNS_DIR, name);
+ const meta = await readRunMetadata(filePath);
+ if (meta.agentName !== 'copilot') {
+ seenIds.add(runId);
+ continue;
+ }
+ if (meta.title && meta.title.toLowerCase().includes(lowerQuery)) {
+ seenIds.add(runId);
+ results.push({
+ type: 'chat',
+ title: meta.title,
+ preview: meta.title,
+ path: runId,
+ });
+ }
+ }
+ } catch {
+ // ignore errors
+ }
+
+ return results;
+}
+
+/**
+ * Use grep to find files matching a query.
+ */
+function grepFiles(query: string, dir: string, includeGlob: string): Promise> {
+ return new Promise((resolve, reject) => {
+ execFile(
+ 'grep',
+ ['-ril', '--include=' + includeGlob, query, dir],
+ { maxBuffer: 1024 * 1024 },
+ (error, stdout) => {
+ if (error) {
+ // Exit code 1 = no matches
+ if (error.code === 1) {
+ resolve([]);
+ return;
+ }
+ reject(error);
+ return;
+ }
+
+ const files = stdout.trim().split('\n').filter(Boolean);
+ // For each matching file, get the first matching line
+ const promises = files.map(file =>
+ getFirstMatchingLine(file, query).then(line => ({ file, line }))
+ );
+ Promise.all(promises).then(resolve).catch(reject);
+ }
+ );
+ });
+}
+
+/**
+ * Get the first line in a file that matches the query (case-insensitive).
+ */
+function getFirstMatchingLine(filePath: string, query: string): Promise {
+ return new Promise((resolve) => {
+ let resolved = false;
+ const done = (value: string) => {
+ if (resolved) return;
+ resolved = true;
+ resolve(value);
+ };
+
+ const lowerQuery = query.toLowerCase();
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
+
+ rl.on('line', (line) => {
+ if (line.toLowerCase().includes(lowerQuery)) {
+ done(line);
+ rl.close();
+ stream.destroy();
+ }
+ });
+
+ rl.on('close', () => done(''));
+ stream.on('error', () => done(''));
+ });
+}
+
+interface RunMetadata {
+ title: string | undefined;
+ agentName: string | undefined;
+}
+
+/**
+ * Read metadata from a run JSONL file (agent name from start event, title from first user message).
+ */
+function readRunMetadata(filePath: string): Promise {
+ return new Promise((resolve) => {
+ let resolved = false;
+ const done = (value: RunMetadata) => {
+ if (resolved) return;
+ resolved = true;
+ resolve(value);
+ };
+
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
+ let lineIndex = 0;
+ let agentName: string | undefined;
+
+ rl.on('line', (line) => {
+ if (resolved) return;
+ const trimmed = line.trim();
+ if (!trimmed) return;
+
+ try {
+ if (lineIndex === 0) {
+ // Start event — extract agentName
+ const start = JSON.parse(trimmed);
+ agentName = start.agentName;
+ lineIndex++;
+ return;
+ }
+
+ const event = JSON.parse(trimmed);
+ if (event.type === 'message') {
+ const msg = event.message;
+ if (msg?.role === 'user') {
+ const content = msg.content;
+ if (typeof content === 'string' && content.trim()) {
+ let cleaned = content.replace(/[\s\S]*?<\/attached-files>/g, '');
+ cleaned = cleaned.replace(/\s+/g, ' ').trim();
+ if (cleaned) {
+ done({ title: cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned, agentName });
+ rl.close();
+ stream.destroy();
+ return;
+ }
+ }
+ done({ title: undefined, agentName });
+ rl.close();
+ stream.destroy();
+ return;
+ } else if (msg?.role === 'assistant') {
+ done({ title: undefined, agentName });
+ rl.close();
+ stream.destroy();
+ return;
+ }
+ }
+ lineIndex++;
+ } catch {
+ lineIndex++;
+ }
+ });
+
+ rl.on('close', () => done({ title: undefined, agentName }));
+ rl.on('error', () => done({ title: undefined, agentName: undefined }));
+ stream.on('error', () => {
+ rl.close();
+ done({ title: undefined, agentName: undefined });
+ });
+ });
+}
+
+/**
+ * Recursively list all .md files in a directory.
+ */
+async function listMarkdownFiles(dir: string): Promise {
+ const results: string[] = [];
+ try {
+ const entries = await fsp.readdir(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ const nested = await listMarkdownFiles(fullPath);
+ results.push(...nested);
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
+ results.push(fullPath);
+ }
+ }
+ } catch {
+ // ignore
+ }
+ return results;
+}
+
+/**
+ * Read the first N non-empty lines of a file for preview.
+ */
+async function readFirstLines(filePath: string, n: number): Promise {
+ return new Promise((resolve) => {
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
+ const lines: string[] = [];
+
+ rl.on('line', (line) => {
+ const trimmed = line.trim();
+ if (trimmed && !trimmed.startsWith('#')) {
+ lines.push(trimmed);
+ }
+ if (lines.length >= n) {
+ rl.close();
+ stream.destroy();
+ }
+ });
+
+ rl.on('close', () => {
+ resolve(lines.join(' ').substring(0, 150));
+ });
+
+ stream.on('error', () => {
+ resolve('');
+ });
+ });
+}
diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts
index 7af39efb..1fa9b423 100644
--- a/apps/x/packages/shared/src/ipc.ts
+++ b/apps/x/packages/shared/src/ipc.ts
@@ -396,6 +396,22 @@ const ipcSchemas = {
req: z.object({ path: z.string() }),
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
},
+ // Search channels
+ 'search:query': {
+ req: z.object({
+ query: z.string(),
+ limit: z.number().optional(),
+ types: z.array(z.enum(['knowledge', 'chat'])).optional(),
+ }),
+ res: z.object({
+ results: z.array(z.object({
+ type: z.enum(['knowledge', 'chat']),
+ title: z.string(),
+ preview: z.string(),
+ path: z.string(),
+ })),
+ }),
+ },
} as const;
// ============================================================================