From 29898d06fa2a07901c917546ede81942d32ea379 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti <13schishti@gmail.com> Date: Fri, 13 Feb 2026 09:19:03 +0000 Subject: [PATCH 01/36] Upgrade GitHub Actions for Node 24 compatibility Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com> --- .github/workflows/electron-build.yml | 18 +++++++++--------- .github/workflows/rowboat-build.yml | 8 ++++---- .github/workflows/x-publish.yml | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index 4bd774c2..6566f105 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -21,7 +21,7 @@ jobs: version: 9 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 cache: 'pnpm' @@ -107,7 +107,7 @@ jobs: fi - name: Upload workflow artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: distributables path: apps/x/apps/main/out/make/* @@ -118,7 +118,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -126,7 +126,7 @@ jobs: version: 9 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 cache: 'pnpm' @@ -171,7 +171,7 @@ jobs: working-directory: apps/x/apps/main - name: Upload workflow artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: distributables-linux path: apps/x/apps/main/out/make/* @@ -182,7 +182,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -190,7 +190,7 @@ jobs: version: 9 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 cache: 'pnpm' @@ -237,7 +237,7 @@ jobs: working-directory: apps/x/apps/main - name: Upload workflow artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: distributables-windows path: apps/x/apps/main/out/make/* diff --git a/.github/workflows/rowboat-build.yml b/.github/workflows/rowboat-build.yml index ef2f93fa..270e6263 100644 --- a/.github/workflows/rowboat-build.yml +++ b/.github/workflows/rowboat-build.yml @@ -8,10 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: cache-dependency-path: 'apps/rowboat/package-lock.json' node-version: '20' @@ -29,10 +29,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: cache-dependency-path: 'apps/rowboat/package-lock.json' node-version: '24' diff --git a/.github/workflows/x-publish.yml b/.github/workflows/x-publish.yml index 4f58b1df..c411ab68 100644 --- a/.github/workflows/x-publish.yml +++ b/.github/workflows/x-publish.yml @@ -12,10 +12,10 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 24 registry-url: https://registry.npmjs.org/ From c107d7ca8403144cb1a7100465fbb092a49c4397 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Tue, 17 Feb 2026 13:53:28 +0530 Subject: [PATCH 02/36] refactor: integrate context menu for delete action in sidebar tasks section --- .../src/components/sidebar-content.tsx | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 51b95109..e38624de 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -1054,36 +1054,37 @@ function TasksSection({ {runs.map((run) => ( - - actions?.onSelectRun(run.id)} - > -
- {processingRunIds?.has(run.id) ? ( - - ) : null} - {run.title || '(Untitled chat)'} - {run.createdAt ? ( - - {formatRunTime(run.createdAt)} - - ) : null} - {!processingRunIds?.has(run.id) && ( - - )} -
-
+ + + + actions?.onSelectRun(run.id)} + > +
+ {processingRunIds?.has(run.id) ? ( + + ) : null} + {run.title || '(Untitled chat)'} + {run.createdAt ? ( + + {formatRunTime(run.createdAt)} + + ) : null} +
+
+
+ + setPendingDeleteRunId(run.id)} + > + + Delete + + +
))}
From f22087cbc3990c06bffbd576b6f85c72a5511a9c Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Wed, 18 Feb 2026 13:23:22 +0530 Subject: [PATCH 03/36] fix: update userId in executeAction to a static value and improve error logging - Changed userId from connectedAccountId to a static value 'rowboat-user'. - Enhanced error logging to include detailed error information in JSON format. --- apps/x/packages/core/src/composio/client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts index 97d1e8b2..070b4642 100644 --- a/apps/x/packages/core/src/composio/client.ts +++ b/apps/x/packages/core/src/composio/client.ts @@ -343,7 +343,7 @@ export async function executeAction( try { const client = getComposioClient(); const result = await client.tools.execute(actionSlug, { - userId: connectedAccountId, + userId: 'rowboat-user', arguments: input, connectedAccountId, dangerouslySkipVersionCheck: true, @@ -352,8 +352,8 @@ export async function executeAction( console.log(`[Composio] Action completed successfully`); return { success: true, data: result.data }; } catch (error) { - console.error(`[Composio] Action execution failed:`, error); - const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(`[Composio] Action execution failed:`, JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}), 2)); + const message = error instanceof Error ? error.message : (typeof error === 'object' ? JSON.stringify(error) : 'Unknown error'); return { success: false, data: null, error: message }; } } From d23f2167eeadc8cd462219787643b83d23de2a3e Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:36:13 +0530 Subject: [PATCH 04/36] css changes to tiptap --- apps/x/apps/renderer/src/styles/editor.css | 151 ++++++++++++--------- 1 file changed, 90 insertions(+), 61 deletions(-) diff --git a/apps/x/apps/renderer/src/styles/editor.css b/apps/x/apps/renderer/src/styles/editor.css index d94f0ffa..dc7c6c5e 100644 --- a/apps/x/apps/renderer/src/styles/editor.css +++ b/apps/x/apps/renderer/src/styles/editor.css @@ -16,8 +16,17 @@ position: relative; } +/* Notion-like base typography */ .tiptap-editor .ProseMirror { - padding: 1rem; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif, + "Segoe UI Emoji", "Segoe UI Symbol"; + font-size: 16px; + line-height: 1.5; + color: rgb(55, 53, 47); + max-width: 720px; + margin: 0 auto; + padding: 2rem 4rem; outline: none; } @@ -27,47 +36,45 @@ /* Placeholder */ .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before { - color: var(--muted-foreground); + color: rgba(55, 53, 47, 0.4); content: attr(data-placeholder); float: left; height: 0; pointer-events: none; } -/* Typography */ -.tiptap-editor .ProseMirror { - font-size: 1rem; - line-height: 1.75; - color: var(--foreground); -} - -.tiptap-editor .ProseMirror > * + * { - margin-top: 0.75em; +/* Paragraphs */ +.tiptap-editor .ProseMirror p { + margin: 1px 0; + padding: 3px 2px; } /* Headings */ .tiptap-editor .ProseMirror h1 { - font-size: 2em; - font-weight: 700; - line-height: 1.2; - margin-top: 1.5em; - margin-bottom: 0.5em; + font-size: 1.875em; + font-weight: 600; + line-height: 1.3; + margin-top: 2em; + margin-bottom: 4px; + padding: 3px 2px; } .tiptap-editor .ProseMirror h2 { font-size: 1.5em; font-weight: 600; line-height: 1.3; - margin-top: 1.25em; - margin-bottom: 0.5em; + margin-top: 1.1em; + margin-bottom: 1px; + padding: 3px 2px; } .tiptap-editor .ProseMirror h3 { font-size: 1.25em; font-weight: 600; - line-height: 1.4; + line-height: 1.3; margin-top: 1em; - margin-bottom: 0.5em; + margin-bottom: 1px; + padding: 3px 2px; } .tiptap-editor .ProseMirror h1:first-child, @@ -76,16 +83,11 @@ margin-top: 0; } -/* Paragraphs */ -.tiptap-editor .ProseMirror p { - margin: 0; -} - /* Lists */ .tiptap-editor .ProseMirror ul, .tiptap-editor .ProseMirror ol { - padding-left: 1.5em; - margin: 0.5em 0; + padding-left: 1.625em; + margin: 1px 0; } .tiptap-editor .ProseMirror ul { @@ -97,7 +99,7 @@ } .tiptap-editor .ProseMirror li { - margin: 0.25em 0; + padding: 3px 0; } .tiptap-editor .ProseMirror li p { @@ -106,50 +108,56 @@ /* Blockquote */ .tiptap-editor .ProseMirror blockquote { - border-left: 3px solid var(--border); - padding-left: 1em; + border-left: 3px solid rgb(55, 53, 47); + padding-left: 14px; + margin: 4px 0; margin-left: 0; margin-right: 0; - color: var(--muted-foreground); - font-style: italic; } -/* Code */ -.tiptap-editor .ProseMirror code { - background-color: var(--muted); - border-radius: 0.25em; - padding: 0.15em 0.3em; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 0.9em; -} - -/* Code Block */ +/* Code blocks */ .tiptap-editor .ProseMirror pre { - background-color: var(--muted); - border-radius: 0.5em; - padding: 1em; + background: rgb(247, 246, 243); + border-radius: 4px; + padding: 2rem; + font-family: "SFMono-Regular", Menlo, Consolas, "PT Mono", + "Liberation Mono", Courier, monospace; + font-size: 0.85em; + line-height: 1.5; + margin: 8px 0; overflow-x: auto; - margin: 0.75em 0; } .tiptap-editor .ProseMirror pre code { background: none; padding: 0; - font-size: 0.875em; + font-size: inherit; color: inherit; + border-radius: 0; } -/* Horizontal Rule */ +/* Inline code */ +.tiptap-editor .ProseMirror code { + background: rgba(135, 131, 120, 0.15); + border-radius: 3px; + padding: 0.2em 0.4em; + font-family: "SFMono-Regular", Menlo, Consolas, monospace; + font-size: 0.85em; + color: #eb5757; +} + +/* Divider */ .tiptap-editor .ProseMirror hr { border: none; - border-top: 1px solid var(--border); - margin: 1.5em 0; + border-top: 1px solid rgba(55, 53, 47, 0.16); + margin: 8px 0; } /* Links */ .tiptap-editor .ProseMirror a { - color: var(--primary); + color: inherit; text-decoration: underline; + text-decoration-color: rgba(55, 53, 47, 0.4); text-underline-offset: 2px; cursor: pointer; } @@ -175,14 +183,13 @@ .tiptap-editor .ProseMirror ul[data-type="taskList"] { list-style: none; padding-left: 0; - margin: 0.5em 0; + margin: 1px 0; } .tiptap-editor .ProseMirror ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5em; - margin: 0.25em 0; } .tiptap-editor .ProseMirror ul[data-type="taskList"] li > label { @@ -238,14 +245,6 @@ align-self: center; } -/* Content area centering */ -.tiptap-editor .ProseMirror { - margin-left: 20%; - margin-right: 20%; - padding-left: 1rem; - padding-right: 1rem; -} - .wiki-link-anchor { position: absolute; height: 0; @@ -327,3 +326,33 @@ background-color: var(--primary); transition: width 0.3s ease; } + +/* Dark mode overrides */ +.dark .tiptap-editor .ProseMirror { + color: rgba(255, 255, 255, 0.9); +} + +.dark .tiptap-editor .ProseMirror a { + text-decoration-color: rgba(255, 255, 255, 0.4); +} + +.dark .tiptap-editor .ProseMirror blockquote { + border-left-color: rgba(255, 255, 255, 0.4); +} + +.dark .tiptap-editor .ProseMirror hr { + border-top-color: rgba(255, 255, 255, 0.16); +} + +.dark .tiptap-editor .ProseMirror pre { + background: rgba(255, 255, 255, 0.05); +} + +.dark .tiptap-editor .ProseMirror code { + background: rgba(255, 255, 255, 0.1); + color: #ff7b72; +} + +.dark .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before { + color: rgba(255, 255, 255, 0.3); +} From 5e2be4531ab851e611058aab3a29d730996501d4 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:43:23 +0530 Subject: [PATCH 05/36] fix assistant instruction for output format --- .../core/src/application/assistant/instructions.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 5e0ce472..c0365c0f 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -125,6 +125,18 @@ Always consult this catalog first so you load the right skills before taking act - Summarize completed work and suggest logical next steps at the end of a task. - Always ask for confirmation before taking destructive actions. +## Output Formatting +- Use **H3** (###) for section headers in longer responses. Never use H1 or H2 — they're too large for chat. +- Use **bold** for key terms, names, or concepts the user should notice. +- Keep bullet points short (1-2 lines each). Use them for lists of 3+ items, not for general prose. +- Use numbered lists only when order matters (steps, rankings). +- For short answers (1-3 sentences), just use plain prose. No headers, no bullets. +- Use code blocks with language tags (\`\`\`python, \`\`\`json, etc.) for any code or config. +- Use inline \`code\` for file names, commands, variable names, or short technical references. +- Add a blank line between sections for breathing room. +- Never start a response with a heading. Lead with a sentence or two of context first. +- Avoid deeply nested bullets. If nesting beyond 2 levels, restructure. + ## MCP Tool Discovery (CRITICAL) **ALWAYS check for MCP tools BEFORE saying you can't do something.** From 7329d0ad0dc20c00581641c445f5b5a6e7e26923 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:08:24 +0530 Subject: [PATCH 06/36] Add global search across knowledge and chats Cmd+K / Ctrl+K opens a spotlight-style search dialog that searches knowledge files (by content and filename) and chat history (by title and message content). Results are grouped by type with filter toggles preselected based on the active sidebar tab. Co-Authored-By: Claude Opus 4.6 --- apps/x/apps/main/src/ipc.ts | 5 + apps/x/apps/renderer/src/App.tsx | 32 +- .../renderer/src/components/search-dialog.tsx | 208 ++++++++++ apps/x/packages/core/src/search/search.ts | 375 ++++++++++++++++++ apps/x/packages/shared/src/ipc.ts | 16 + 5 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 apps/x/apps/renderer/src/components/search-dialog.tsx create mode 100644 apps/x/packages/core/src/search/search.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 72e1e589..b0757881 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -30,6 +30,7 @@ import * as composioHandler from './composio-handler.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js'; +import { search } from '@x/core/dist/search/search.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -497,5 +498,9 @@ export function setupIpcHandlers() { const mimeType = mimeMap[ext] || 'application/octet-stream'; return { data: buffer.toString('base64'), mimeType, size: stat.size }; }, + // Search handler + 'search:query': async (_event, args) => { + return search(args.query, args.limit, args.types); + }, }); } diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7658c4c8..ed3dd739 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; import { Button } from './components/ui/button'; -import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, X, ChevronLeftIcon, ChevronRightIcon, SquarePen } from 'lucide-react'; +import { CheckIcon, LoaderIcon, ArrowUp, PanelLeftIcon, PanelRightIcon, Square, 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'; @@ -50,6 +50,7 @@ import { TooltipProvider } from "@/components/ui/tooltip" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { OnboardingModal } from '@/components/onboarding-modal' +import { SearchDialog } from '@/components/search-dialog' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' @@ -675,6 +676,9 @@ function App() { // Onboarding state const [showOnboarding, setShowOnboarding] = useState(false) + // Search state + const [isSearchOpen, setIsSearchOpen] = useState(false) + // Background tasks state type BackgroundTaskItem = { name: string @@ -1829,6 +1833,18 @@ function App() { return () => document.removeEventListener('keydown', handleKeyDown) }, [handleCloseFullScreenChat, isFullScreenChat, expandedFrom, navigateToFullScreenChat]) + // Keyboard shortcut: Cmd+K / Ctrl+K to open search + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault() + setIsSearchOpen(true) + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, []) + const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { navigateToFile(path) @@ -2336,6 +2352,14 @@ function App() { {headerTitle} + {selectedPath && (
{isSaving ? ( @@ -2568,6 +2592,12 @@ function App() { /> )}
+ { void navigateToView({ type: 'chat', runId: id }) }} + /> void + onSelectFile: (path: string) => void + onSelectRun: (runId: string) => void +} + +export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) { + const { activeSection } = useSidebarSection() + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isSearching, setIsSearching] = useState(false) + const [activeTypes, setActiveTypes] = useState>( + () => new Set(activeTabToTypes(activeSection)) + ) + const debouncedQuery = useDebounce(query, 250) + + // Sync filter preselection when dialog opens + useEffect(() => { + if (open) { + setActiveTypes(new Set(activeTabToTypes(activeSection))) + } + }, [open, activeSection]) + + const toggleType = useCallback((type: SearchType) => { + setActiveTypes(new Set([type])) + }, []) + + useEffect(() => { + if (!debouncedQuery.trim()) { + setResults([]) + return + } + + let cancelled = false + setIsSearching(true) + + const types = Array.from(activeTypes) as ('knowledge' | 'chat')[] + window.ipc.invoke('search:query', { query: debouncedQuery, limit: 20, types }) + .then((res) => { + if (!cancelled) { + setResults(res.results) + } + }) + .catch((err) => { + console.error('Search failed:', err) + if (!cancelled) { + setResults([]) + } + }) + .finally(() => { + if (!cancelled) { + setIsSearching(false) + } + }) + + return () => { cancelled = true } + }, [debouncedQuery, activeTypes]) + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setQuery('') + setResults([]) + } + }, [open]) + + const handleSelect = useCallback((result: SearchResult) => { + onOpenChange(false) + if (result.type === 'knowledge') { + onSelectFile(result.path) + } else { + onSelectRun(result.path) + } + }, [onOpenChange, onSelectFile, onSelectRun]) + + const knowledgeResults = results.filter(r => r.type === 'knowledge') + const chatResults = results.filter(r => r.type === 'chat') + + return ( + + +
+ toggleType('knowledge')} + icon={} + label="Knowledge" + /> + toggleType('chat')} + icon={} + label="Chats" + /> +
+ + {!query.trim() && ( + Type to search... + )} + {query.trim() && !isSearching && results.length === 0 && ( + No results found. + )} + {knowledgeResults.length > 0 && ( + + {knowledgeResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} + {chatResults.length > 0 && ( + + {chatResults.map((result) => ( + handleSelect(result)} + > + +
+ {result.title} + {result.preview} +
+
+ ))} +
+ )} +
+
+ ) +} + +function FilterToggle({ + active, + onClick, + icon, + label, +}: { + active: boolean + onClick: () => void + icon: React.ReactNode + label: string +}) { + return ( + + ) +} diff --git a/apps/x/packages/core/src/search/search.ts b/apps/x/packages/core/src/search/search.ts new file mode 100644 index 00000000..d68449f5 --- /dev/null +++ b/apps/x/packages/core/src/search/search.ts @@ -0,0 +1,375 @@ +import path from 'path'; +import fs from 'fs'; +import fsp from 'fs/promises'; +import readline from 'readline'; +import { execFile } from 'child_process'; +import { WorkDir } from '../config/config.js'; + +interface SearchResult { + type: 'knowledge' | 'chat'; + title: string; + preview: string; + path: string; +} + +const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge'); +const RUNS_DIR = path.join(WorkDir, 'runs'); + +type SearchType = 'knowledge' | 'chat'; + +/** + * Search across knowledge files and chat history. + * @param types - optional filter to search only specific types (default: both) + */ +export async function search(query: string, limit = 20, types?: SearchType[]): Promise<{ results: SearchResult[] }> { + const trimmed = query.trim(); + if (!trimmed) { + return { results: [] }; + } + + const searchKnowledgeEnabled = !types || types.includes('knowledge'); + const searchChatsEnabled = !types || types.includes('chat'); + + const [knowledgeResults, chatResults] = await Promise.all([ + searchKnowledgeEnabled ? searchKnowledge(trimmed, limit) : Promise.resolve([]), + searchChatsEnabled ? searchChats(trimmed, limit) : Promise.resolve([]), + ]); + + const results = [...knowledgeResults, ...chatResults].slice(0, limit); + return { results }; +} + +/** + * Search knowledge markdown files by content and filename. + */ +async function searchKnowledge(query: string, limit: number): Promise { + if (!fs.existsSync(KNOWLEDGE_DIR)) { + return []; + } + + const results: SearchResult[] = []; + const seenPaths = new Set(); + const lowerQuery = query.toLowerCase(); + + // Content search via grep + try { + const grepMatches = await grepFiles(query, KNOWLEDGE_DIR, '*.md'); + for (const match of grepMatches) { + if (results.length >= limit) break; + const relPath = path.relative(WorkDir, match.file); + if (seenPaths.has(relPath)) continue; + seenPaths.add(relPath); + + const title = path.basename(match.file, '.md'); + results.push({ + type: 'knowledge', + title, + preview: match.line.trim().substring(0, 150), + path: relPath, + }); + } + } catch { + // grep failed (no matches or dir issue) — continue + } + + // Filename search — check files whose name matches the query + try { + const allFiles = await listMarkdownFiles(KNOWLEDGE_DIR); + for (const file of allFiles) { + if (results.length >= limit) break; + const relPath = path.relative(WorkDir, file); + if (seenPaths.has(relPath)) continue; + + const basename = path.basename(file, '.md'); + if (basename.toLowerCase().includes(lowerQuery)) { + seenPaths.add(relPath); + const preview = await readFirstLines(file, 2); + results.push({ + type: 'knowledge', + title: basename, + preview, + path: relPath, + }); + } + } + } catch { + // ignore errors + } + + return results; +} + +/** + * Search chat history by title and message content. + */ +async function searchChats(query: string, limit: number): Promise { + if (!fs.existsSync(RUNS_DIR)) { + return []; + } + + const results: SearchResult[] = []; + const seenIds = new Set(); + const lowerQuery = query.toLowerCase(); + + // Content search via grep on JSONL files + try { + const grepMatches = await grepFiles(query, RUNS_DIR, '*.jsonl'); + for (const match of grepMatches) { + if (results.length >= limit) break; + const runId = path.basename(match.file, '.jsonl'); + if (seenIds.has(runId)) continue; + + const meta = await readRunMetadata(match.file); + if (meta.agentName !== 'copilot') { + seenIds.add(runId); + continue; + } + seenIds.add(runId); + + // Extract a content preview from the matching line + let preview = ''; + try { + const parsed = JSON.parse(match.line); + if (parsed.message?.content && typeof parsed.message.content === 'string') { + preview = parsed.message.content.replace(/[\s\S]*?<\/attached-files>/g, '').trim().substring(0, 150); + } + } catch { + preview = match.line.substring(0, 150); + } + + results.push({ + type: 'chat', + title: meta.title || runId, + preview, + path: runId, + }); + } + } catch { + // grep failed — continue + } + + // Title search — scan run files for matching titles + try { + const entries = await fsp.readdir(RUNS_DIR, { withFileTypes: true }); + const jsonlFiles = entries + .filter(e => e.isFile() && e.name.endsWith('.jsonl')) + .map(e => e.name) + .sort() + .reverse(); // newest first + + for (const name of jsonlFiles) { + if (results.length >= limit) break; + const runId = path.basename(name, '.jsonl'); + if (seenIds.has(runId)) continue; + + const filePath = path.join(RUNS_DIR, name); + const meta = await readRunMetadata(filePath); + if (meta.agentName !== 'copilot') { + seenIds.add(runId); + continue; + } + if (meta.title && meta.title.toLowerCase().includes(lowerQuery)) { + seenIds.add(runId); + results.push({ + type: 'chat', + title: meta.title, + preview: meta.title, + path: runId, + }); + } + } + } catch { + // ignore errors + } + + return results; +} + +/** + * Use grep to find files matching a query. + */ +function grepFiles(query: string, dir: string, includeGlob: string): Promise> { + return new Promise((resolve, reject) => { + execFile( + 'grep', + ['-ril', '--include=' + includeGlob, query, dir], + { maxBuffer: 1024 * 1024 }, + (error, stdout) => { + if (error) { + // Exit code 1 = no matches + if (error.code === 1) { + resolve([]); + return; + } + reject(error); + return; + } + + const files = stdout.trim().split('\n').filter(Boolean); + // For each matching file, get the first matching line + const promises = files.map(file => + getFirstMatchingLine(file, query).then(line => ({ file, line })) + ); + Promise.all(promises).then(resolve).catch(reject); + } + ); + }); +} + +/** + * Get the first line in a file that matches the query (case-insensitive). + */ +function getFirstMatchingLine(filePath: string, query: string): Promise { + return new Promise((resolve) => { + let resolved = false; + const done = (value: string) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + + const lowerQuery = query.toLowerCase(); + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + rl.on('line', (line) => { + if (line.toLowerCase().includes(lowerQuery)) { + done(line); + rl.close(); + stream.destroy(); + } + }); + + rl.on('close', () => done('')); + stream.on('error', () => done('')); + }); +} + +interface RunMetadata { + title: string | undefined; + agentName: string | undefined; +} + +/** + * Read metadata from a run JSONL file (agent name from start event, title from first user message). + */ +function readRunMetadata(filePath: string): Promise { + return new Promise((resolve) => { + let resolved = false; + const done = (value: RunMetadata) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + let lineIndex = 0; + let agentName: string | undefined; + + rl.on('line', (line) => { + if (resolved) return; + const trimmed = line.trim(); + if (!trimmed) return; + + try { + if (lineIndex === 0) { + // Start event — extract agentName + const start = JSON.parse(trimmed); + agentName = start.agentName; + lineIndex++; + return; + } + + const event = JSON.parse(trimmed); + if (event.type === 'message') { + const msg = event.message; + if (msg?.role === 'user') { + const content = msg.content; + if (typeof content === 'string' && content.trim()) { + let cleaned = content.replace(/[\s\S]*?<\/attached-files>/g, ''); + cleaned = cleaned.replace(/\s+/g, ' ').trim(); + if (cleaned) { + done({ title: cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned, agentName }); + rl.close(); + stream.destroy(); + return; + } + } + done({ title: undefined, agentName }); + rl.close(); + stream.destroy(); + return; + } else if (msg?.role === 'assistant') { + done({ title: undefined, agentName }); + rl.close(); + stream.destroy(); + return; + } + } + lineIndex++; + } catch { + lineIndex++; + } + }); + + rl.on('close', () => done({ title: undefined, agentName })); + rl.on('error', () => done({ title: undefined, agentName: undefined })); + stream.on('error', () => { + rl.close(); + done({ title: undefined, agentName: undefined }); + }); + }); +} + +/** + * Recursively list all .md files in a directory. + */ +async function listMarkdownFiles(dir: string): Promise { + const results: string[] = []; + try { + const entries = await fsp.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const nested = await listMarkdownFiles(fullPath); + results.push(...nested); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + results.push(fullPath); + } + } + } catch { + // ignore + } + return results; +} + +/** + * Read the first N non-empty lines of a file for preview. + */ +async function readFirstLines(filePath: string, n: number): Promise { + return new Promise((resolve) => { + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + const lines: string[] = []; + + rl.on('line', (line) => { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + lines.push(trimmed); + } + if (lines.length >= n) { + rl.close(); + stream.destroy(); + } + }); + + rl.on('close', () => { + resolve(lines.join(' ').substring(0, 150)); + }); + + stream.on('error', () => { + resolve(''); + }); + }); +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 7af39efb..1fa9b423 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -396,6 +396,22 @@ const ipcSchemas = { req: z.object({ path: z.string() }), res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }), }, + // Search channels + 'search:query': { + req: z.object({ + query: z.string(), + limit: z.number().optional(), + types: z.array(z.enum(['knowledge', 'chat'])).optional(), + }), + res: z.object({ + results: z.array(z.object({ + type: z.enum(['knowledge', 'chat']), + title: z.string(), + preview: z.string(), + path: z.string(), + })), + }), + }, } as const; // ============================================================================ From dee38a615fbc1a3b99ff254683d57c4fc993029f Mon Sep 17 00:00:00 2001 From: arkml <6592213+arkml@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:17:14 +0530 Subject: [PATCH 07/36] Remove Rowboat Web Studio reference from README Removed section about Rowboat Web Studio from README. --- README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.md b/README.md index 9ba7e099..640ee35c 100644 --- a/README.md +++ b/README.md @@ -141,11 +141,6 @@ Examples: Exa (web search), Twitter/X, ElevenLabs (voice), Slack, Linear/Jira, G - No proprietary formats or hosted lock-in - You can inspect, edit, back up, or delete everything at any time - -## Looking for Rowboat Web Studio? - -If you’re looking for Rowboat web Studio, start [here](https://docs.rowboatlabs.com/). - ---
From d7b423404c30dcb5735e027fab16ad3414986d5e Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:07:24 +0530 Subject: [PATCH 08/36] add more debug logs for llm-events --- apps/x/packages/core/src/agents/runtime.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 09d1c721..8b1bd54e 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -827,10 +827,6 @@ export async function* streamAgent({ tools, signal, )) { - // Only log significant events (not text-delta to reduce noise) - if (event.type !== 'text-delta') { - loopLogger.log('got llm-stream-event:', event.type); - } messageBuilder.ingest(event); yield* processEvent({ runId, @@ -924,9 +920,11 @@ async function* streamLlm( tools: ToolSet, signal?: AbortSignal, ): AsyncGenerator, void, unknown> { + const converted = convertFromMessages(messages); + console.log(`! SENDING payload to model: `, JSON.stringify(converted)) const { fullStream } = streamText({ model, - messages: convertFromMessages(messages), + messages: converted, system: instructions, tools, stopWhen: stepCountIs(1), @@ -935,7 +933,7 @@ async function* streamLlm( for await (const event of fullStream) { // Check abort on every chunk for responsiveness signal?.throwIfAborted(); - // console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event)); + console.log("-> \t\tstream event", JSON.stringify(event)); switch (event.type) { case "error": yield { From 737c3a75b435c0cc1cdfc15ce85c5b7d7bf86a97 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:07:54 +0530 Subject: [PATCH 09/36] send reasoning blocks back to llm --- apps/x/packages/core/src/agents/runtime.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 8b1bd54e..4150d958 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -226,13 +226,14 @@ export class StreamStepMessageBuilder { private textBuffer: string = ""; private reasoningBuffer: string = ""; private providerOptions: z.infer | undefined = undefined; + private reasoningProviderOptions: z.infer | undefined = undefined; flushBuffers() { - // skip reasoning - // if (this.reasoningBuffer) { - // this.parts.push({ type: "reasoning", text: this.reasoningBuffer }); - // this.reasoningBuffer = ""; - // } + if (this.reasoningBuffer || this.reasoningProviderOptions) { + this.parts.push({ type: "reasoning", text: this.reasoningBuffer, providerOptions: this.reasoningProviderOptions }); + this.reasoningBuffer = ""; + this.reasoningProviderOptions = undefined; + } if (this.textBuffer) { this.parts.push({ type: "text", text: this.textBuffer }); this.textBuffer = ""; @@ -242,7 +243,11 @@ export class StreamStepMessageBuilder { ingest(event: z.infer) { switch (event.type) { case "reasoning-start": + break; case "reasoning-end": + this.reasoningProviderOptions = event.providerOptions; + this.flushBuffers(); + break; case "text-start": case "text-end": this.flushBuffers(); From 097efb39b17bcfec174ce4fb79b0d6eb178d0b3d Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:08:24 +0530 Subject: [PATCH 10/36] fix tool-call / text part order in step-message builder --- apps/x/packages/core/src/agents/runtime.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 4150d958..4012e9b3 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -971,6 +971,12 @@ async function* streamLlm( providerOptions: event.providerMetadata, }; break; + case "text-end": + yield { + type: "text-end", + providerOptions: event.providerMetadata, + }; + break; case "text-delta": yield { type: "text-delta", From 383241b5b7de1ea7a3bb6dde22e1e7d6cd13b3f9 Mon Sep 17 00:00:00 2001 From: Tushar <47842976+tusharmagar@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:23:20 +0530 Subject: [PATCH 11/36] Add tabbed view support for chats and knowledge --- apps/x/apps/renderer/src/App.tsx | 1470 +++++++++++------ .../components/ai-elements/conversation.tsx | 24 +- .../components/ai-elements/prompt-input.tsx | 8 +- .../components/chat-input-with-mentions.tsx | 201 +++ .../renderer/src/components/chat-sidebar.tsx | 871 +++++----- .../src/components/sidebar-content.tsx | 74 +- .../apps/renderer/src/components/tab-bar.tsx | 95 ++ .../renderer/src/lib/chat-conversation.ts | 177 ++ apps/x/apps/renderer/src/styles/editor.css | 10 + 9 files changed, 1894 insertions(+), 1036 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx create mode 100644 apps/x/apps/renderer/src/components/tab-bar.tsx create mode 100644 apps/x/apps/renderer/src/lib/chat-conversation.ts 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 && ( -