Merge pull request #390 from rowboatlabs/dev

Dev
This commit is contained in:
Tushar 2026-02-19 23:59:19 +05:30 committed by GitHub
commit 1f55e5b949
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 266 additions and 90 deletions

View file

@ -5,8 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
import type { LanguageModelUsage, ToolUIPart } from 'ai'; import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css' import './App.css'
import z from 'zod'; import z from 'zod';
import { Button } from './components/ui/button'; import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, 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 { cn } from '@/lib/utils';
import { MarkdownEditor } from './components/markdown-editor'; import { MarkdownEditor } from './components/markdown-editor';
import { ChatSidebar } from './components/chat-sidebar'; import { ChatSidebar } from './components/chat-sidebar';
@ -100,8 +99,9 @@ const TITLEBAR_BUTTON_PX = 32
const TITLEBAR_BUTTON_GAP_PX = 4 const TITLEBAR_BUTTON_GAP_PX = 4
const TITLEBAR_HEADER_GAP_PX = 8 const TITLEBAR_HEADER_GAP_PX = 8
const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12 const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
const TITLEBAR_BUTTONS_COLLAPSED = 4 const TITLEBAR_BUTTONS_COLLAPSED = 5
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const clampNumber = (value: number, min: number, max: number) => const clampNumber = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value)) Math.min(max, Math.max(min, value))
@ -134,6 +134,18 @@ const getBaseName = (path: string) => {
return file.replace(/\.md$/i, '') return file.replace(/\.md$/i, '')
} }
const getAncestorDirectoryPaths = (path: string): string[] => {
const parts = path.split('/').filter(Boolean)
if (parts.length <= 2) return []
const ancestors: string[] = []
for (let i = 1; i < parts.length - 1; i++) {
ancestors.push(parts.slice(0, i + 1).join('/'))
}
return ancestors
}
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => { const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
if (!usage) return null if (!usage) return null
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number') const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
@ -224,6 +236,7 @@ function FixedSidebarToggle({
canNavigateBack, canNavigateBack,
canNavigateForward, canNavigateForward,
onNewChat, onNewChat,
onOpenSearch,
leftInsetPx, leftInsetPx,
}: { }: {
onNavigateBack: () => void onNavigateBack: () => void
@ -231,6 +244,7 @@ function FixedSidebarToggle({
canNavigateBack: boolean canNavigateBack: boolean
canNavigateForward: boolean canNavigateForward: boolean
onNewChat: () => void onNewChat: () => void
onOpenSearch: () => void
leftInsetPx: number leftInsetPx: number
}) { }) {
const { toggleSidebar, state } = useSidebar() const { toggleSidebar, state } = useSidebar()
@ -257,6 +271,15 @@ function FixedSidebarToggle({
> >
<SquarePen className="size-5" /> <SquarePen className="size-5" />
</button> </button>
<button
type="button"
onClick={onOpenSearch}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
aria-label="Search"
>
<SearchIcon className="size-5" />
</button>
{/* Back / Forward navigation */} {/* Back / Forward navigation */}
{isCollapsed && ( {isCollapsed && (
<> <>
@ -309,7 +332,7 @@ function ContentHeader({
"titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border px-3 bg-sidebar transition-[padding] duration-200 ease-linear overflow-hidden", "titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border px-3 bg-sidebar transition-[padding] duration-200 ease-linear overflow-hidden",
// When the sidebar is collapsed the content area shifts left, so we need enough left padding // When the sidebar is collapsed the content area shifts left, so we need enough left padding
// to avoid overlapping the fixed traffic-lights/toggle/back/forward controls. // to avoid overlapping the fixed traffic-lights/toggle/back/forward controls.
isCollapsed && !collapsedLeftPaddingPx && "pl-[168px]" isCollapsed && !collapsedLeftPaddingPx && "pl-[196px]"
)} )}
style={isCollapsed && collapsedLeftPaddingPx ? { paddingLeft: collapsedLeftPaddingPx } : undefined} style={isCollapsed && collapsedLeftPaddingPx ? { paddingLeft: collapsedLeftPaddingPx } : undefined}
> >
@ -493,6 +516,7 @@ function App() {
const newFileTabId = () => `file-tab-${++fileTabIdCounterRef.current}` const newFileTabId = () => `file-tab-${++fileTabIdCounterRef.current}`
const getFileTabTitle = useCallback((tab: FileTab) => { const getFileTabTitle = useCallback((tab: FileTab) => {
if (isGraphTabPath(tab.path)) return 'Graph View'
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
}, []) }, [])
@ -583,6 +607,25 @@ function App() {
} }
}, [selectedPath]) }, [selectedPath])
// Keep active file visible in the Knowledge tree by auto-expanding its ancestor folders.
useEffect(() => {
if (!selectedPath) return
const ancestorDirs = getAncestorDirectoryPaths(selectedPath)
if (ancestorDirs.length === 0) return
setExpandedPaths((prev) => {
let changed = false
const next = new Set(prev)
for (const dirPath of ancestorDirs) {
if (!next.has(dirPath)) {
next.add(dirPath)
changed = true
}
}
return changed ? next : prev
})
}, [selectedPath])
// Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures) // Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures)
useEffect(() => { useEffect(() => {
runIdRef.current = runId runIdRef.current = runId
@ -624,23 +667,28 @@ function App() {
processingRunIdsRef.current = processingRunIds processingRunIdsRef.current = processingRunIds
}, [processingRunIds]) }, [processingRunIds])
// Sync active run streaming UI with background tracking // Sync active run streaming UI with background processing tracking.
// Depend on both runId and processingRunIds so we don't miss late/early event ordering.
useEffect(() => { useEffect(() => {
if (!runId) { if (!runId) {
setIsProcessing(false) setIsProcessing(false)
setIsStopping(false)
setStopClickedAt(null)
setCurrentAssistantMessage('') setCurrentAssistantMessage('')
return return
} }
const isRunProcessing = processingRunIdsRef.current.has(runId) const isRunProcessing = processingRunIds.has(runId)
setIsProcessing(isRunProcessing) setIsProcessing(isRunProcessing)
if (isRunProcessing) { if (isRunProcessing) {
const buffer = streamingBuffersRef.current.get(runId) const buffer = streamingBuffersRef.current.get(runId)
setCurrentAssistantMessage(buffer?.assistant ?? '') setCurrentAssistantMessage(buffer?.assistant ?? '')
} else { } else {
setIsStopping(false)
setStopClickedAt(null)
setCurrentAssistantMessage('') setCurrentAssistantMessage('')
streamingBuffersRef.current.delete(runId) streamingBuffersRef.current.delete(runId)
} }
}, [runId]) }, [runId, processingRunIds])
// Load directory tree // Load directory tree
const loadDirectory = useCallback(async () => { const loadDirectory = useCallback(async () => {
@ -1163,7 +1211,14 @@ function App() {
break break
case 'start': case 'start':
setProcessingRunIds(prev => {
if (prev.has(event.runId)) return prev
const next = new Set(prev)
next.add(event.runId)
return next
})
if (!isActiveRun) return if (!isActiveRun) return
setIsProcessing(true)
setCurrentAssistantMessage('') setCurrentAssistantMessage('')
setModelUsage(null) setModelUsage(null)
break break
@ -1171,12 +1226,20 @@ function App() {
case 'llm-stream-event': case 'llm-stream-event':
{ {
const llmEvent = event.event const llmEvent = event.event
// Fallback: if processing-start is missed/out-of-order, stream activity still means run is active.
setProcessingRunIds(prev => {
if (prev.has(event.runId)) return prev
const next = new Set(prev)
next.add(event.runId)
return next
})
if (!isActiveRun) { if (!isActiveRun) {
if (llmEvent.type === 'text-delta' && llmEvent.delta) { if (llmEvent.type === 'text-delta' && llmEvent.delta) {
appendStreamingBuffer(event.runId, llmEvent.delta) appendStreamingBuffer(event.runId, llmEvent.delta)
} }
return return
} }
setIsProcessing(true)
if (llmEvent.type === 'text-delta' && llmEvent.delta) { if (llmEvent.type === 'text-delta' && llmEvent.delta) {
appendStreamingBuffer(event.runId, llmEvent.delta) appendStreamingBuffer(event.runId, llmEvent.delta)
setCurrentAssistantMessage(prev => prev + llmEvent.delta) setCurrentAssistantMessage(prev => prev + llmEvent.delta)
@ -1592,8 +1655,10 @@ function App() {
const restoreChatTabState = useCallback((tabId: string, fallbackRunId: string | null): boolean => { const restoreChatTabState = useCallback((tabId: string, fallbackRunId: string | null): boolean => {
const cached = chatViewStateByTabRef.current[tabId] const cached = chatViewStateByTabRef.current[tabId]
if (!cached) return false if (!cached) return false
// Ignore stale cache snapshots that don't match the tab's current run binding.
if (cached.runId !== fallbackRunId) return false
const resolvedRunId = cached.runId ?? fallbackRunId const resolvedRunId = fallbackRunId
setRunId(resolvedRunId) setRunId(resolvedRunId)
setConversation(cached.conversation) setConversation(cached.conversation)
setCurrentAssistantMessage(cached.currentAssistantMessage) setCurrentAssistantMessage(cached.currentAssistantMessage)
@ -1615,6 +1680,8 @@ function App() {
const openChatInNewTab = useCallback((targetRunId: string) => { const openChatInNewTab = useCallback((targetRunId: string) => {
const existingTab = chatTabs.find(t => t.runId === targetRunId) const existingTab = chatTabs.find(t => t.runId === targetRunId)
if (existingTab) { if (existingTab) {
// Cancel stale in-flight loads from previously focused tabs.
loadRunRequestIdRef.current += 1
setActiveChatTabId(existingTab.id) setActiveChatTabId(existingTab.id)
const restored = restoreChatTabState(existingTab.id, existingTab.runId) const restored = restoreChatTabState(existingTab.id, existingTab.runId)
if (processingRunIdsRef.current.has(targetRunId) || !restored) { if (processingRunIdsRef.current.has(targetRunId) || !restored) {
@ -1633,6 +1700,8 @@ function App() {
if (!tab) return if (!tab) return
if (tabId === activeChatTabId) return if (tabId === activeChatTabId) return
saveChatScrollForTab(activeChatTabId) saveChatScrollForTab(activeChatTabId)
// Cancel stale in-flight loads from previously focused tabs.
loadRunRequestIdRef.current += 1
setActiveChatTabId(tabId) setActiveChatTabId(tabId)
const restored = restoreChatTabState(tabId, tab.runId) const restored = restoreChatTabState(tabId, tab.runId)
if (tab.runId && processingRunIdsRef.current.has(tab.runId)) { if (tab.runId && processingRunIdsRef.current.has(tab.runId)) {
@ -1669,6 +1738,8 @@ function App() {
if (tabId === activeChatTabId && nextTabs.length > 0) { if (tabId === activeChatTabId && nextTabs.length > 0) {
const newIdx = Math.min(idx, nextTabs.length - 1) const newIdx = Math.min(idx, nextTabs.length - 1)
const newActiveTab = nextTabs[newIdx] const newActiveTab = nextTabs[newIdx]
// Cancel stale in-flight loads from the closing tab.
loadRunRequestIdRef.current += 1
setActiveChatTabId(newActiveTab.id) setActiveChatTabId(newActiveTab.id)
const restored = restoreChatTabState(newActiveTab.id, newActiveTab.runId) const restored = restoreChatTabState(newActiveTab.id, newActiveTab.runId)
if (newActiveTab.runId && processingRunIdsRef.current.has(newActiveTab.runId)) { if (newActiveTab.runId && processingRunIdsRef.current.has(newActiveTab.runId)) {
@ -1758,12 +1829,14 @@ function App() {
const existingTab = fileTabs.find(t => t.path === path) const existingTab = fileTabs.find(t => t.path === path)
if (existingTab) { if (existingTab) {
setActiveFileTabId(existingTab.id) setActiveFileTabId(existingTab.id)
setIsGraphOpen(false)
setSelectedPath(path) setSelectedPath(path)
return return
} }
const id = newFileTabId() const id = newFileTabId()
setFileTabs(prev => [...prev, { id, path }]) setFileTabs(prev => [...prev, { id, path }])
setActiveFileTabId(id) setActiveFileTabId(id)
setIsGraphOpen(false)
setSelectedPath(path) setSelectedPath(path)
}, [fileTabs]) }, [fileTabs])
@ -1771,12 +1844,24 @@ function App() {
const tab = fileTabs.find(t => t.id === tabId) const tab = fileTabs.find(t => t.id === tabId)
if (!tab) return if (!tab) return
setActiveFileTabId(tabId) setActiveFileTabId(tabId)
setSelectedBackgroundTask(null)
setExpandedFrom(null)
// If chat-only maximize is active, drop back to a visible knowledge layout.
if (isRightPaneMaximized) {
setIsRightPaneMaximized(false)
}
if (isGraphTabPath(tab.path)) {
setSelectedPath(null)
setIsGraphOpen(true)
return
}
setIsGraphOpen(false)
setSelectedPath(tab.path) setSelectedPath(tab.path)
}, [fileTabs]) }, [fileTabs, isRightPaneMaximized])
const closeFileTab = useCallback((tabId: string) => { const closeFileTab = useCallback((tabId: string) => {
const closingTab = fileTabs.find(t => t.id === tabId) const closingTab = fileTabs.find(t => t.id === tabId)
if (closingTab) { if (closingTab && !isGraphTabPath(closingTab.path)) {
removeEditorCacheForPath(closingTab.path) removeEditorCacheForPath(closingTab.path)
initialContentByPathRef.current.delete(closingTab.path) initialContentByPathRef.current.delete(closingTab.path)
if (editorPathRef.current === closingTab.path) { if (editorPathRef.current === closingTab.path) {
@ -1788,15 +1873,23 @@ function App() {
// Last file tab - close it and go back to chat // Last file tab - close it and go back to chat
setActiveFileTabId(null) setActiveFileTabId(null)
setSelectedPath(null) setSelectedPath(null)
setIsGraphOpen(false)
return [] return []
} }
const idx = prev.findIndex(t => t.id === tabId) const idx = prev.findIndex(t => t.id === tabId)
if (idx === -1) return prev
const next = prev.filter(t => t.id !== tabId) const next = prev.filter(t => t.id !== tabId)
if (tabId === activeFileTabId && next.length > 0) { if (tabId === activeFileTabId && next.length > 0) {
const newIdx = Math.min(idx, next.length - 1) const newIdx = Math.min(idx, next.length - 1)
const newActiveTab = next[newIdx] const newActiveTab = next[newIdx]
setActiveFileTabId(newActiveTab.id) setActiveFileTabId(newActiveTab.id)
setSelectedPath(newActiveTab.path) if (isGraphTabPath(newActiveTab.path)) {
setSelectedPath(null)
setIsGraphOpen(true)
} else {
setIsGraphOpen(false)
setSelectedPath(newActiveTab.path)
}
} }
return next return next
}) })
@ -1887,22 +1980,62 @@ function App() {
return [...stack, entry] return [...stack, entry]
}, []) }, [])
const ensureFileTabForPath = useCallback((path: string) => {
const existingTab = fileTabs.find((tab) => tab.path === path)
if (existingTab) {
setActiveFileTabId(existingTab.id)
return
}
if (activeFileTabId) {
const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId)
if (activeTab && !isGraphTabPath(activeTab.path)) {
setFileTabs((prev) => prev.map((tab) => (
tab.id === activeFileTabId ? { ...tab, path } : tab
)))
return
}
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path }])
setActiveFileTabId(id)
}, [fileTabs, activeFileTabId])
const ensureGraphFileTab = useCallback(() => {
const existingGraphTab = fileTabs.find((tab) => isGraphTabPath(tab.path))
if (existingGraphTab) {
setActiveFileTabId(existingGraphTab.id)
return
}
const id = newFileTabId()
setFileTabs((prev) => [...prev, { id, path: GRAPH_TAB_PATH }])
setActiveFileTabId(id)
}, [fileTabs])
const applyViewState = useCallback(async (view: ViewState) => { const applyViewState = useCallback(async (view: ViewState) => {
switch (view.type) { switch (view.type) {
case 'file': case 'file':
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setIsGraphOpen(false) setIsGraphOpen(false)
setExpandedFrom(null) setExpandedFrom(null)
setIsChatSidebarOpen(true) // Preserve split vs knowledge-max mode when navigating knowledge files.
setIsRightPaneMaximized(false) // Only exit chat-only maximize, because that would hide the selected file.
if (isRightPaneMaximized) {
setIsRightPaneMaximized(false)
}
setSelectedPath(view.path) setSelectedPath(view.path)
ensureFileTabForPath(view.path)
return return
case 'graph': case 'graph':
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
setSelectedPath(null) setSelectedPath(null)
setExpandedFrom(null) setExpandedFrom(null)
setIsGraphOpen(true) setIsGraphOpen(true)
setIsRightPaneMaximized(false) ensureGraphFileTab()
if (isRightPaneMaximized) {
setIsRightPaneMaximized(false)
}
return return
case 'task': case 'task':
setSelectedPath(null) setSelectedPath(null)
@ -1924,7 +2057,7 @@ function App() {
} }
return return
} }
}, [handleNewChat, loadRun]) }, [ensureFileTabForPath, ensureGraphFileTab, handleNewChat, isRightPaneMaximized, loadRun])
const navigateToView = useCallback(async (nextView: ViewState) => { const navigateToView = useCallback(async (nextView: ViewState) => {
const current = currentViewState const current = currentViewState
@ -1993,22 +2126,8 @@ function App() {
}, [viewHistory.forward, currentViewState]) }, [viewHistory.forward, currentViewState])
const navigateToFile = useCallback((path: string) => { const navigateToFile = useCallback((path: string) => {
// If already open in a file tab, switch to it
const existingTab = fileTabs.find(t => t.path === path)
if (existingTab) {
switchFileTab(existingTab.id)
return
}
// Update current file tab or create one if none exists
if (activeFileTabId) {
setFileTabs(prev => prev.map(t => t.id === activeFileTabId ? { ...t, path } : t))
} else {
const id = newFileTabId()
setFileTabs(prev => [...prev, { id, path }])
setActiveFileTabId(id)
}
void navigateToView({ type: 'file', path }) void navigateToView({ type: 'file', path })
}, [navigateToView, fileTabs, activeFileTabId, switchFileTab]) }, [navigateToView])
const navigateToFullScreenChat = useCallback(() => { const navigateToFullScreenChat = useCallback(() => {
// Only treat this as navigation when coming from another view // Only treat this as navigation when coming from another view
@ -2099,8 +2218,13 @@ function App() {
const targetPane: ShortcutPane = rightPaneAvailable const targetPane: ShortcutPane = rightPaneAvailable
? (isRightPaneMaximized ? 'right' : activeShortcutPane) ? (isRightPaneMaximized ? 'right' : activeShortcutPane)
: 'left' : 'left'
const inFileView = targetPane === 'left' && Boolean(selectedPath) const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen)
const targetFileTabId = activeFileTabId ?? fileTabs.find((tab) => tab.path === selectedPath)?.id ?? null const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : selectedPath
const targetFileTabId = activeFileTabId ?? (
selectedKnowledgePath
? (fileTabs.find((tab) => tab.path === selectedKnowledgePath)?.id ?? null)
: null
)
// Cmd+W — close active tab // Cmd+W — close active tab
if (e.key === 'w') { if (e.key === 'w') {
@ -2270,6 +2394,11 @@ function App() {
} }
}, },
openGraph: () => { openGraph: () => {
// From chat-only landing state, open graph directly in full knowledge view.
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
setIsChatSidebarOpen(false)
setIsRightPaneMaximized(false)
}
void navigateToView({ type: 'graph' }) void navigateToView({ type: 'graph' })
}, },
expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))), expandAll: () => setExpandedPaths(new Set(collectDirPaths(tree))),
@ -2335,7 +2464,7 @@ function App() {
onOpenInNewTab: (path: string) => { onOpenInNewTab: (path: string) => {
openFileInNewTab(path) openFileInNewTab(path)
}, },
}), [tree, selectedPath, workspaceRoot, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab, removeEditorCacheForPath]) }), [tree, selectedPath, isGraphOpen, selectedBackgroundTask, workspaceRoot, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab, removeEditorCacheForPath])
// Handler for when a voice note is created/updated // Handler for when a voice note is created/updated
const handleVoiceNoteCreated = useCallback(async (notePath: string) => { const handleVoiceNoteCreated = useCallback(async (notePath: string) => {
@ -2619,6 +2748,7 @@ function App() {
: null : null
const isRightPaneContext = Boolean(selectedPath || isGraphOpen) const isRightPaneContext = Boolean(selectedPath || isGraphOpen)
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
const shouldCollapseLeftPane = isRightPaneOnlyMode
const openMarkdownTabs = React.useMemo(() => { const openMarkdownTabs = React.useMemo(() => {
const markdownTabs = fileTabs.filter(tab => tab.path.endsWith('.md')) const markdownTabs = fileTabs.filter(tab => tab.path.endsWith('.md'))
if (selectedPath?.endsWith('.md')) { if (selectedPath?.endsWith('.md')) {
@ -2655,7 +2785,6 @@ function App() {
onSelectRun: (runIdToLoad) => { onSelectRun: (runIdToLoad) => {
if (selectedPath || isGraphOpen) { if (selectedPath || isGraphOpen) {
setIsChatSidebarOpen(true) setIsChatSidebarOpen(true)
setIsRightPaneMaximized(false)
} }
// If already open in a chat tab, switch to it // If already open in a chat tab, switch to it
@ -2715,9 +2844,13 @@ function App() {
backgroundTasks={backgroundTasks} backgroundTasks={backgroundTasks}
selectedBackgroundTask={selectedBackgroundTask} selectedBackgroundTask={selectedBackgroundTask}
/> />
{!isRightPaneOnlyMode && (
<SidebarInset <SidebarInset
className="overflow-hidden! min-h-0 min-w-0" className={cn(
"overflow-hidden! min-h-0 min-w-0 transition-[max-width] duration-200 ease-linear",
shouldCollapseLeftPane && "pointer-events-none select-none"
)}
style={shouldCollapseLeftPane ? { maxWidth: 0 } : { maxWidth: '100%' }}
aria-hidden={shouldCollapseLeftPane}
onMouseDownCapture={() => setActiveShortcutPane('left')} onMouseDownCapture={() => setActiveShortcutPane('left')}
onFocusCapture={() => setActiveShortcutPane('left')} onFocusCapture={() => setActiveShortcutPane('left')}
> >
@ -2729,7 +2862,7 @@ function App() {
canNavigateForward={canNavigateForward} canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx} collapsedLeftPaddingPx={collapsedLeftPaddingPx}
> >
{selectedPath && fileTabs.length >= 1 ? ( {(selectedPath || isGraphOpen) && fileTabs.length >= 1 ? (
<TabBar <TabBar
tabs={fileTabs} tabs={fileTabs}
activeTabId={activeFileTabId ?? ''} activeTabId={activeFileTabId ?? ''}
@ -2737,6 +2870,7 @@ function App() {
getTabId={(t) => t.id} getTabId={(t) => t.id}
onSwitchTab={switchFileTab} onSwitchTab={switchFileTab}
onCloseTab={closeFileTab} onCloseTab={closeFileTab}
allowSingleTabClose={fileTabs.length === 1 && isGraphOpen}
/> />
) : ( ) : (
<TabBar <TabBar
@ -2749,14 +2883,6 @@ function App() {
onCloseTab={closeChatTab} onCloseTab={closeChatTab}
/> />
)} )}
<button
type="button"
onClick={() => setIsSearchOpen(true)}
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
aria-label="Search"
>
<SearchIcon className="size-4" />
</button>
{selectedPath && ( {selectedPath && (
<div className="flex items-center gap-1 text-xs text-muted-foreground self-center shrink-0 pl-2"> <div className="flex items-center gap-1 text-xs text-muted-foreground self-center shrink-0 pl-2">
{isSaving ? ( {isSaving ? (
@ -2772,25 +2898,35 @@ function App() {
) : null} ) : null}
</div> </div>
)} )}
{!selectedPath && isGraphOpen && ( {!selectedPath && !isGraphOpen && !selectedTask && (
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="sm" <button
onClick={() => { void navigateToView({ type: 'chat', runId }) }} type="button"
className="titlebar-no-drag text-foreground self-center shrink-0" onClick={handleNewChatTab}
> className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0"
Close Graph aria-label="New chat tab"
</Button> >
<SquarePen className="size-5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">New chat tab</TooltipContent>
</Tooltip>
)} )}
{!selectedPath && !isGraphOpen && expandedFrom && ( {!selectedPath && !isGraphOpen && expandedFrom && (
<button <Tooltip>
type="button" <TooltipTrigger asChild>
onClick={handleCloseFullScreenChat} <button
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0" type="button"
aria-label="Return to file" onClick={handleCloseFullScreenChat}
> className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0"
<X className="size-5" /> aria-label="Restore two-pane view"
</button> >
<Minimize2 className="size-5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
</Tooltip>
)} )}
{(selectedPath || isGraphOpen) && ( {(selectedPath || isGraphOpen) && (
<Tooltip> <Tooltip>
@ -2799,17 +2935,13 @@ function App() {
type="button" type="button"
onClick={toggleKnowledgePane} onClick={toggleKnowledgePane}
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors -mr-1 self-center shrink-0" className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors -mr-1 self-center shrink-0"
aria-label={isChatSidebarOpen aria-label={isChatSidebarOpen ? "Maximize knowledge view" : "Restore two-pane view"}
? (selectedPath ? "Maximize knowledge view" : "Maximize main view")
: "Restore two-pane view"}
> >
{isChatSidebarOpen ? <Expand className="size-5" /> : <Shrink className="size-5" />} {isChatSidebarOpen ? <Maximize2 className="size-5" /> : <Minimize2 className="size-5" />}
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">
{isChatSidebarOpen {isChatSidebarOpen ? "Maximize knowledge view" : "Restore two-pane view"}
? (selectedPath ? "Maximize knowledge view" : "Maximize main view")
: "Restore two-pane view"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
@ -3009,7 +3141,6 @@ function App() {
</FileCardProvider> </FileCardProvider>
)} )}
</SidebarInset> </SidebarInset>
)}
{/* Chat sidebar - shown when viewing files/graph */} {/* Chat sidebar - shown when viewing files/graph */}
{isRightPaneContext && ( {isRightPaneContext && (
@ -3058,6 +3189,7 @@ function App() {
canNavigateBack={canNavigateBack} canNavigateBack={canNavigateBack}
canNavigateForward={canNavigateForward} canNavigateForward={canNavigateForward}
onNewChat={handleNewChatTab} onNewChat={handleNewChatTab}
onOpenSearch={() => setIsSearchOpen(true)}
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0} leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
/> />
</SidebarProvider> </SidebarProvider>

View file

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Expand, Shrink, SquarePen } from 'lucide-react' import { Maximize2, Minimize2, SquarePen } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -154,6 +154,8 @@ export function ChatSidebar({
const paneRef = useRef<HTMLDivElement>(null) const paneRef = useRef<HTMLDivElement>(null)
const startXRef = useRef(0) const startXRef = useRef(0)
const startWidthRef = useRef(0) const startWidthRef = useRef(0)
const prevIsMaximizedRef = useRef(isMaximized)
const justToggledMaximize = prevIsMaximizedRef.current !== isMaximized
const getMaxAllowedWidth = useCallback(() => { const getMaxAllowedWidth = useCallback(() => {
if (typeof window === 'undefined') return MAX_WIDTH if (typeof window === 'undefined') return MAX_WIDTH
@ -183,6 +185,10 @@ export function ChatSidebar({
setShowContent(false) setShowContent(false)
}, [isOpen]) }, [isOpen])
useEffect(() => {
prevIsMaximizedRef.current = isMaximized
}, [isMaximized])
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
try { try {
@ -343,7 +349,7 @@ export function ChatSidebar({
onFocusCapture={onActivate} onFocusCapture={onActivate}
className={cn( className={cn(
'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background', 'relative flex min-w-0 flex-col overflow-hidden border-l border-border bg-background',
!isResizing && 'transition-[width] duration-200 ease-linear' !isResizing && !justToggledMaximize && 'transition-[width] duration-200 ease-linear'
)} )}
style={paneStyle} style={paneStyle}
> >
@ -394,7 +400,7 @@ export function ChatSidebar({
className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground" className="titlebar-no-drag my-1 mr-2 h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={isMaximized ? 'Restore two-pane view' : 'Maximize chat view'} aria-label={isMaximized ? 'Restore two-pane view' : 'Maximize chat view'}
> >
{isMaximized ? <Shrink className="size-5" /> : <Expand className="size-5" />} {isMaximized ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">

View file

@ -820,6 +820,36 @@ function KnowledgeSection({
onVoiceNoteCreated?: (path: string) => void onVoiceNoteCreated?: (path: string) => void
}) { }) {
const isExpanded = expandedPaths.size > 0 const isExpanded = expandedPaths.size > 0
const treeContainerRef = React.useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!selectedPath) return
let cancelled = false
let rafId: number | null = null
let attempts = 0
const maxAttempts = 20
const revealActiveFile = () => {
if (cancelled) return
const container = treeContainerRef.current
if (!container) return
const activeRow = container.querySelector<HTMLElement>('[data-knowledge-active="true"]')
if (activeRow) {
activeRow.scrollIntoView({ block: "nearest", inline: "nearest" })
return
}
if (attempts >= maxAttempts) return
attempts += 1
rafId = requestAnimationFrame(revealActiveFile)
}
rafId = requestAnimationFrame(revealActiveFile)
return () => {
cancelled = true
if (rafId !== null) cancelAnimationFrame(rafId)
}
}, [selectedPath, expandedPaths, tree])
const quickActions = [ const quickActions = [
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() }, { icon: FilePlus, label: "New Note", action: () => actions.createNote() },
@ -865,18 +895,20 @@ function KnowledgeSection({
</Tooltip> </Tooltip>
</div> </div>
<SidebarGroupContent className="flex-1 overflow-y-auto"> <SidebarGroupContent className="flex-1 overflow-y-auto">
<SidebarMenu> <div ref={treeContainerRef}>
{tree.map((item, index) => ( <SidebarMenu>
<Tree {tree.map((item, index) => (
key={index} <Tree
item={item} key={index}
selectedPath={selectedPath} item={item}
expandedPaths={expandedPaths} selectedPath={selectedPath}
onSelect={onSelectFile} expandedPaths={expandedPaths}
actions={actions} onSelect={onSelectFile}
/> actions={actions}
))} />
</SidebarMenu> ))}
</SidebarMenu>
</div>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</ContextMenuTrigger> </ContextMenuTrigger>
@ -935,7 +967,7 @@ function Tree({
try { try {
await actions.rename(item.path, trimmedName, isDir) await actions.rename(item.path, trimmedName, isDir)
toast('Renamed successfully', 'success') toast('Renamed successfully', 'success')
} catch (err) { } catch {
toast('Failed to rename', 'error') toast('Failed to rename', 'error')
} }
} }
@ -950,7 +982,7 @@ function Tree({
try { try {
await actions.remove(item.path) await actions.remove(item.path)
toast('Moved to trash', 'success') toast('Moved to trash', 'success')
} catch (err) { } catch {
toast('Failed to delete', 'error') toast('Failed to delete', 'error')
} }
} }
@ -1045,7 +1077,11 @@ function Tree({
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<SidebarMenuItem className="group/file-item"> <SidebarMenuItem
className="group/file-item"
data-knowledge-file-path={item.path}
data-knowledge-active={isSelected ? "true" : "false"}
>
<SidebarMenuButton <SidebarMenuButton
isActive={isSelected} isActive={isSelected}
onClick={(e) => { onClick={(e) => {

View file

@ -21,6 +21,7 @@ interface TabBarProps<T> {
onSwitchTab: (tabId: string) => void onSwitchTab: (tabId: string) => void
onCloseTab: (tabId: string) => void onCloseTab: (tabId: string) => void
layout?: 'fill' | 'scroll' layout?: 'fill' | 'scroll'
allowSingleTabClose?: boolean
} }
export function TabBar<T>({ export function TabBar<T>({
@ -32,6 +33,7 @@ export function TabBar<T>({
onSwitchTab, onSwitchTab,
onCloseTab, onCloseTab,
layout = 'fill', layout = 'fill',
allowSingleTabClose = false,
}: TabBarProps<T>) { }: TabBarProps<T>) {
return ( return (
<div <div
@ -69,7 +71,7 @@ export function TabBar<T>({
<span className="size-1.5 shrink-0 rounded-full bg-emerald-500 animate-pulse" /> <span className="size-1.5 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
)} )}
<span className="truncate flex-1 text-left">{title}</span> <span className="truncate flex-1 text-left">{title}</span>
{tabs.length > 1 && ( {(allowSingleTabClose || tabs.length > 1) && (
<span <span
role="button" role="button"
className="shrink-0 flex items-center justify-center rounded-sm p-0.5 opacity-0 group-hover/tab:opacity-60 hover:opacity-100! hover:bg-foreground/10 transition-all" className="shrink-0 flex items-center justify-center rounded-sm p-0.5 opacity-0 group-hover/tab:opacity-60 hover:opacity-100! hover:bg-foreground/10 transition-all"