diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index ed3dd739..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, SearchIcon } 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,7 +43,7 @@ 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' @@ -54,6 +51,22 @@ 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" @@ -67,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 @@ -139,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) => { @@ -219,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) => { @@ -293,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 ? ( - - ) : ( - - )} -
- ) -} - -// 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 } @@ -561,7 +306,7 @@ function ContentHeader({ return (
{!isCollapsed && onNavigateBack && onNavigateForward ? ( -
+
{selectedPath && ( -
+
{isSaving ? ( <> @@ -2380,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 @@ -2389,21 +2786,32 @@ function App() { )} {(selectedPath || isGraphOpen) && ( - + + + + + + {isChatSidebarOpen + ? (selectedPath ? "Maximize knowledge view" : "Maximize main view") + : "Restore two-pane view"} + + )} @@ -2422,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} + /> +
+ ) + })}
) : (
@@ -2455,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... + + + )} + + )} + + +
+ ) + })} +
@@ -2526,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 */} @@ -2579,18 +3057,10 @@ 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)} - /> - )}
{ - 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 ? ( + + ) : ( + + )} +
+ ) +} + +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 && ( - - Full screen chat + + {isMaximized ? 'Restore two-pane view' : 'Maximize chat view'} + )}
- {/* Conversation area */} - {})}> -
- - - - {!hasConversation ? ( - -
-
- Ask anything... -
-
-
- ) : ( - <> - {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 && ( -