mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-01 03:16:29 +02:00
commit
1f55e5b949
4 changed files with 266 additions and 90 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue