From abb9f9b2ca97c8f64cf924fde449ed71194d6b63 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Wed, 18 Feb 2026 17:29:57 +0530 Subject: [PATCH] refactor chat components to improve state management and enhance UI responsiveness; replace ChatInputBar with ChatInputWithMentions, and update ChatSidebar and TabBar for better tab handling --- apps/x/apps/renderer/src/App.tsx | 887 ++++++++++-------- .../components/chat-input-with-mentions.tsx | 195 ++++ .../renderer/src/components/chat-sidebar.tsx | 712 ++++++-------- .../apps/renderer/src/components/tab-bar.tsx | 20 +- apps/x/apps/renderer/src/styles/editor.css | 10 + 5 files changed, 1035 insertions(+), 789 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 8a8e7d44..096fba53 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, SearchIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, PanelLeftIcon, Expand, Minimize2, 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'; @@ -95,6 +92,24 @@ type ConversationItem = ChatMessage | ToolCall | ErrorMessage; type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; +type ChatTabViewState = { + runId: string | null + conversation: ConversationItem[] + currentAssistantMessage: string + pendingAskHumanRequests: Map> + allPermissionRequests: Map> + permissionResponses: Map +} + +const createEmptyChatTabViewState = (): ChatTabViewState => ({ + runId: null, + conversation: [], + currentAssistantMessage: '', + pendingAskHumanRequests: new Map(), + allPermissionRequests: new Map(), + permissionResponses: new Map(), +}) + const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item const isErrorMessage = (item: ConversationItem): item is ErrorMessage => 'kind' in item && item.kind === 'error' @@ -175,6 +190,13 @@ const parseAttachedFiles = (content: string): { message: string; files: string[] return { message: cleanMessage.trim(), files } } +const inferRunTitleFromMessage = (content: string): string | undefined => { + const { message } = parseAttachedFiles(content) + const normalized = message.replace(/\s+/g, ' ').trim() + if (!normalized) return undefined + return normalized.length > 100 ? normalized.substring(0, 100) : normalized +} + const untitledBaseName = 'untitled' const getHeadingTitle = (markdown: string) => { @@ -294,193 +316,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 - initialDraft?: string - onDraftChange?: (text: string) => void -} - -function ChatInputInner({ - onSubmit, - onStop, - isProcessing, - isStopping, - presetMessage, - onPresetMessageConsumed, - runId, - initialDraft, - onDraftChange, -}: ChatInputInnerProps) { - const controller = usePromptInputController() - const message = controller.textInput.value - const canSubmit = Boolean(message.trim()) && !isProcessing - - // Restore draft on mount - useEffect(() => { - if (initialDraft) { - controller.textInput.setInput(initialDraft) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) // only on mount - - // Save draft on every change - useEffect(() => { - onDraftChange?.(message) - }, [message, onDraftChange]) - - // 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 ? ( - - ) : ( - - )} -
- ) -} - -// 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 - initialDraft?: string - onDraftChange?: (text: string) => void -} - -function ChatInputWithMentions({ - knowledgeFiles, - recentFiles, - visibleFiles, - onSubmit, - onStop, - isProcessing, - isStopping, - presetMessage, - onPresetMessageConsumed, - runId, - initialDraft, - onDraftChange, -}: ChatInputWithMentionsProps) { - return ( - - - - ) -} - /** A snapshot of which view the user is on */ type ViewState = | { type: 'chat'; runId: string | null } @@ -628,6 +463,8 @@ function App() { 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([]) @@ -640,7 +477,7 @@ function App() { const [graphStatus, setGraphStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') const [graphError, setGraphError] = useState(null) const [isChatSidebarOpen, setIsChatSidebarOpen] = useState(true) - const [activeSection, setActiveSection] = useState('tasks') + const [isRightPaneMaximized, setIsRightPaneMaximized] = useState(false) const isMac = typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') const collapsedLeftPaddingPx = (isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0) + @@ -667,7 +504,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) @@ -690,6 +527,10 @@ function App() { // 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()) @@ -724,13 +565,59 @@ function App() { }, []) // 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('') @@ -769,15 +656,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 @@ -848,6 +757,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) @@ -857,7 +767,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(() => { @@ -869,6 +779,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 @@ -887,6 +808,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) @@ -915,7 +839,7 @@ function App() { return () => { cancelled = true } - }, [selectedPath]) + }, [selectedPath, setEditorCacheForPath]) // Track recently opened markdown files for wiki links useEffect(() => { @@ -958,18 +882,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, @@ -1293,6 +1231,7 @@ function App() { next.delete(event.runId) return next }) + void loadRuns() clearStreamingBuffer(event.runId) if (!isActiveRun) return setIsProcessing(false) @@ -1338,6 +1277,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) @@ -1554,11 +1503,13 @@ 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)) @@ -1593,9 +1544,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) @@ -1674,6 +1633,10 @@ function App() { setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) setSelectedBackgroundTask(null) + setChatViewStateByTab(prev => ({ + ...prev, + [activeChatTabIdRef.current]: createEmptyChatTabViewState(), + })) }, []) // Chat tab operations @@ -1695,40 +1658,86 @@ function App() { } }, [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) - loadRun(targetRunId) + 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]) + }, [chatTabs, loadRun, restoreChatTabState]) const switchChatTab = useCallback((tabId: string) => { const tab = chatTabs.find(t => t.id === tabId) if (!tab) return + if (tabId === activeChatTabId) return setActiveChatTabId(tabId) - applyChatTab(tab) - }, [chatTabs, applyChatTab]) + 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]) const closeChatTab = useCallback((tabId: string) => { - setChatTabs(prev => { - if (prev.length <= 1) return prev - const idx = prev.findIndex(t => t.id === tabId) - const next = prev.filter(t => t.id !== tabId) - if (tabId === activeChatTabId && next.length > 0) { - const newIdx = Math.min(idx, next.length - 1) - const newActiveTab = next[newIdx] - setActiveChatTabId(newActiveTab.id) - applyChatTab(newActiveTab) - } + if (chatTabs.length <= 1) return + const idx = chatTabs.findIndex(t => t.id === tabId) + if (idx === -1) return + 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 }) - }, [activeChatTabId, applyChatTab]) + chatDraftsRef.current.delete(tabId) + + 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]) // File tab operations const openFileInNewTab = useCallback((path: string) => { @@ -1752,6 +1761,14 @@ function App() { }, [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 @@ -1769,7 +1786,7 @@ function App() { } return next }) - }, [activeFileTabId]) + }, [activeFileTabId, fileTabs, removeEditorCacheForPath]) const handleNewChatTab = useCallback(() => { // If there's already an empty "New chat" tab, switch to it @@ -1785,33 +1802,54 @@ function App() { setActiveChatTabId(id) } handleNewChat() - // Ensure we're in chat view - if (selectedPath || isGraphOpen || selectedBackgroundTask) { - setSelectedPath(null) - setIsGraphOpen(false) + // In two-pane mode, keep the current knowledge/graph context and just reset chat. + if (selectedPath || isGraphOpen) { + setIsChatSidebarOpen(true) + setIsRightPaneMaximized(false) + return + } + + // Outside two-pane mode, leave task detail view and return to chat. + if (selectedBackgroundTask) { setExpandedFrom(null) setSelectedBackgroundTask(null) } }, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, selectedBackgroundTask]) - const handleChatInputSubmit = (text: string) => { + // 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) { @@ -1821,6 +1859,7 @@ function App() { setSelectedPath(expandedFrom.path) } setExpandedFrom(null) + setIsRightPaneMaximized(false) } }, [expandedFrom]) @@ -1848,6 +1887,8 @@ function App() { setSelectedBackgroundTask(null) setIsGraphOpen(false) setExpandedFrom(null) + setIsChatSidebarOpen(true) + setIsRightPaneMaximized(false) setSelectedPath(view.path) return case 'graph': @@ -1855,17 +1896,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) @@ -2045,13 +2089,14 @@ function App() { const handleTabKeyDown = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey if (!mod) return + const inFileView = Boolean(selectedPath) // Cmd+W — close active tab if (e.key === 'w') { e.preventDefault() - if (activeSection === 'knowledge' && activeFileTabId) { + if (inFileView && activeFileTabId) { closeFileTab(activeFileTabId) - } else if (activeSection === 'tasks') { + } else { closeChatTab(activeChatTabId) } return @@ -2061,11 +2106,11 @@ function App() { if (/^[1-9]$/.test(e.key)) { e.preventDefault() const n = parseInt(e.key, 10) - if (activeSection === 'knowledge') { + if (inFileView) { const idx = e.key === '9' ? fileTabs.length - 1 : n - 1 const tab = fileTabs[idx] if (tab) switchFileTab(tab.id) - } else if (activeSection === 'tasks') { + } else { const idx = e.key === '9' ? chatTabs.length - 1 : n - 1 const tab = chatTabs[idx] if (tab) switchChatTab(tab.id) @@ -2077,12 +2122,12 @@ function App() { if (e.shiftKey && (e.key === ']' || e.key === '[')) { e.preventDefault() const direction = e.key === ']' ? 1 : -1 - if (activeSection === 'knowledge') { + if (inFileView) { const currentIdx = fileTabs.findIndex(t => t.id === activeFileTabId) if (currentIdx === -1) return const nextIdx = (currentIdx + direction + fileTabs.length) % fileTabs.length switchFileTab(fileTabs[nextIdx].id) - } else if (activeSection === 'tasks') { + } else { const currentIdx = chatTabs.findIndex(t => t.id === activeChatTabId) if (currentIdx === -1) return const nextIdx = (currentIdx + direction + chatTabs.length) % chatTabs.length @@ -2093,7 +2138,7 @@ function App() { } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) - }, [activeSection, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) + }, [selectedPath, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { @@ -2110,27 +2155,6 @@ function App() { setExpandedPaths(newExpanded) } - // Handle sidebar section changes - switch to chat view for tasks - const handleSectionChange = useCallback((section: ActiveSection) => { - setActiveSection(section) - if (section === 'tasks') { - if (selectedBackgroundTask) return - if (selectedPath || isGraphOpen) { - void navigateToView({ type: 'chat', runId }) - } - } else if (section === 'knowledge') { - // Restore the active file tab if one exists - const activeTab = fileTabs.find(t => t.id === activeFileTabId) - if (activeTab) { - void navigateToView({ type: 'file', path: activeTab.path }) - } else if (fileTabs.length > 0) { - const tab = fileTabs[0] - setActiveFileTabId(tab.id) - void navigateToView({ type: 'file', path: tab.path }) - } - } - }, [isGraphOpen, navigateToView, runId, selectedBackgroundTask, selectedPath, fileTabs, activeFileTabId]) - // Knowledge quick actions const knowledgeFiles = React.useMemo(() => { const files = collectFilePaths(tree).filter((path) => path.endsWith('.md')) @@ -2247,6 +2271,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) @@ -2256,6 +2301,10 @@ function App() { remove: async (path: string) => { try { await window.ipc.invoke('workspace:remove', { path, opts: { trash: true } }) + 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) { @@ -2275,7 +2324,7 @@ function App() { onOpenInNewTab: (path: string) => { openFileInNewTab(path) }, - }), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab]) + }), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab, removeEditorCacheForPath]) // Handler for when a voice note is created/updated const handleVoiceNoteCreated = useCallback(async (notePath: string) => { @@ -2550,18 +2599,47 @@ function App() { return null } - const hasConversation = conversation.length > 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 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 */} { + if (selectedPath || isGraphOpen) { + handleNewChatTabInSidebar() + setIsChatSidebarOpen(true) + setIsRightPaneMaximized(false) + return + } 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 } - // Navigate current chat tab to this run + // 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 }) }, @@ -2607,10 +2703,19 @@ function App() { } else { // Only one tab, reset it to new chat setChatTabs([{ id: tabForRun.id, runId: null }]) - void navigateToView({ type: 'chat', runId: null }) + if (selectedPath || isGraphOpen) { + handleNewChat() + } else { + void navigateToView({ type: 'chat', runId: null }) + } } } else if (runId === runIdToDelete) { - void navigateToView({ type: 'chat', runId: null }) + 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) { @@ -2624,7 +2729,8 @@ function App() { backgroundTasks={backgroundTasks} selectedBackgroundTask={selectedBackgroundTask} /> - + {!isRightPaneOnlyMode && ( + {/* Header - also serves as titlebar drag region, adjusts padding when sidebar collapsed */} { void navigateBack() }} @@ -2633,7 +2739,7 @@ function App() { canNavigateForward={canNavigateForward} collapsedLeftPaddingPx={collapsedLeftPaddingPx} > - {activeSection === 'knowledge' && fileTabs.length >= 1 ? ( + {selectedPath && fileTabs.length >= 1 ? ( setIsChatSidebarOpen(!isChatSidebarOpen)} + onClick={toggleKnowledgePane} 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 self-center shrink-0" - aria-label="Toggle Chat Sidebar" + aria-label={isChatSidebarOpen + ? (selectedPath ? "Maximize knowledge view" : "Maximize main view") + : "Restore two-pane view"} + title={isChatSidebarOpen + ? (selectedPath ? "Maximize knowledge view" : "Maximize main view") + : "Restore two-pane view"} > - + {isChatSidebarOpen ? : } )} @@ -2723,13 +2834,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} + /> +
+ ) + })}
) : (
@@ -2756,70 +2886,85 @@ 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} - /> - - ) - } - } - return rendered - })} + {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) + 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 + })} - {/* Render pending ask-human requests */} - {Array.from(pendingAskHumanRequests.values()).map((request) => ( - handleAskHumanResponse(request.toolCallId, request.subflow, response)} - isProcessing={isProcessing} - /> - ))} + {Array.from(tabState.pendingAskHumanRequests.values()).map((request) => ( + handleAskHumanResponse(request.toolCallId, request.subflow, response)} + isProcessing={isActive && isProcessing} + /> + ))} - {currentAssistantMessage && ( - - - {currentAssistantMessage} - - - )} + {tabState.currentAssistantMessage && ( + + + {tabState.currentAssistantMessage} + + + )} - {isProcessing && !currentAssistantMessage && ( - - - Thinking... - - - )} - - )} -
-
+ {isActive && isProcessing && !tabState.currentAssistantMessage && ( + + + Thinking... + + + )} + + )} + + +
+ ) + })}
@@ -2848,27 +2993,37 @@ function App() { )} + )} {/* Chat sidebar - shown when viewing files/graph */} - {(selectedPath || isGraphOpen) && ( + {isRightPaneContext && ( setPresetMessage(undefined)} + initialDraft={chatDraftsRef.current.get(activeChatTabId)} + onDraftChange={handleDraftChange} pendingAskHumanRequests={pendingAskHumanRequests} allPermissionRequests={allPermissionRequests} permissionResponses={permissionResponses} @@ -2887,14 +3042,6 @@ function App() { 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 + onStop?: () => void + isProcessing: boolean + isStopping?: boolean + presetMessage?: string + onPresetMessageConsumed?: () => void + runId?: string | null + initialDraft?: string + onDraftChange?: (text: string) => void +} + +function ChatInputInner({ + onSubmit, + onStop, + isProcessing, + isStopping, + 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(() => { + 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]) + + return ( +
+ + {isProcessing ? ( + + ) : ( + + )} +
+ ) +} + +export 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 + initialDraft?: string + onDraftChange?: (text: string) => void +} + +export function ChatInputWithMentions({ + knowledgeFiles, + recentFiles, + visibleFiles, + onSubmit, + onStop, + isProcessing, + isStopping, + 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..f81b430d 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,13 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowUp, Expand, LoaderIcon, SquarePen, Square } from 'lucide-react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Expand, Minimize2, SquarePen } from 'lucide-react' import type { ToolUIPart } from 'ai' +import z from 'zod' + 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,22 +17,17 @@ 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 { 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' +import { TabBar, type ChatTab } from '@/components/tab-bar' +import { ChatInputWithMentions } from '@/components/chat-input-with-mentions' interface ChatMessage { id: string @@ -61,6 +54,14 @@ interface ErrorMessage { type ConversationItem = ChatMessage | ToolCall | ErrorMessage +type ChatTabViewState = { + conversation: ConversationItem[] + currentAssistantMessage: string + pendingAskHumanRequests: Map> + allPermissionRequests: Map> + permissionResponses: Map +} + type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error' const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item @@ -107,28 +108,60 @@ const normalizeToolOutput = (output: ToolCall['result'] | undefined, status: Too 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> + runId?: string | null + presetMessage?: string + onPresetMessageConsumed?: () => void + initialDraft?: string + onDraftChange?: (text: string) => void pendingAskHumanRequests?: Map> allPermissionRequests?: Map> permissionResponses?: Map @@ -140,20 +173,30 @@ interface ChatSidebarProps { 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, + initialDraft, + onDraftChange, pendingAskHumanRequests = new Map(), allPermissionRequests = new Map(), permissionResponses = new Map(), @@ -161,91 +204,62 @@ export function ChatSidebar({ onAskHumanResponse, onOpenKnowledgeFile, }: 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 +267,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,127 +281,33 @@ 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]) - - // 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 activeTabState = useMemo(() => ({ + conversation, + currentAssistantMessage, + pendingAskHumanRequests, + allPermissionRequests, + permissionResponses, + }), [ + conversation, + currentAssistantMessage, + pendingAskHumanRequests, + allPermissionRequests, + permissionResponses, + ]) + const emptyTabState = useMemo(() => ({ + conversation: [], + currentAssistantMessage: '', + pendingAskHumanRequests: new Map(), + allPermissionRequests: new Map(), + permissionResponses: new Map(), + }), []) + 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) const renderConversationItem = (item: ConversationItem) => { if (isChatMessage(item)) { @@ -410,16 +330,10 @@ export function ChatSidebar({ const input = normalizeToolInput(item.input) return ( - + - {output !== null ? ( - - ) : null} + {output !== null ? : null} ) @@ -438,218 +352,188 @@ 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 && ( - - Full screen chat + + {isMaximized ? 'Restore two-pane view' : 'Maximize right pane'} + )}
- {/* Conversation area */} - {})}> -
- - - - {!hasConversation ? ( - -
-
- Ask anything... + {})}> +
+ {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) + 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... + + + )} + + )} +
+
-
- - ) : ( - <> - {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 - })} + ) + })} - {/* 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 && ( -