mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-18 21:21:11 +02:00
feat: implement graph tab functionality and enhance tab management
- Introduced a new graph tab view with dedicated handling for file tabs. - Updated tab navigation logic to support switching between regular and graph tabs. - Added a new prop `allowSingleTabClose` to the TabBar component for improved tab closing behavior. - Refactored existing tab management functions to accommodate the new graph tab functionality.
This commit is contained in:
parent
06444e5db2
commit
f61404cbcd
2 changed files with 88 additions and 43 deletions
|
|
@ -5,7 +5,6 @@ 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, Expand, Shrink, 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 { cn } from '@/lib/utils';
|
||||||
import { MarkdownEditor } from './components/markdown-editor';
|
import { MarkdownEditor } from './components/markdown-editor';
|
||||||
|
|
@ -102,6 +101,7 @@ const TITLEBAR_HEADER_GAP_PX = 8
|
||||||
const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
|
const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
|
||||||
const TITLEBAR_BUTTONS_COLLAPSED = 5
|
const TITLEBAR_BUTTONS_COLLAPSED = 5
|
||||||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4
|
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,8 @@ const getBaseName = (path: string) => {
|
||||||
return file.replace(/\.md$/i, '')
|
return file.replace(/\.md$/i, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
@ -504,6 +506,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
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -1789,12 +1792,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])
|
||||||
|
|
||||||
|
|
@ -1802,12 +1807,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) {
|
||||||
|
|
@ -1819,15 +1836,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
|
||||||
})
|
})
|
||||||
|
|
@ -1918,22 +1943,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)
|
||||||
|
|
@ -1955,7 +2020,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
|
||||||
|
|
@ -2024,22 +2089,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
|
||||||
|
|
@ -2130,8 +2181,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') {
|
||||||
|
|
@ -2760,7 +2816,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 ?? ''}
|
||||||
|
|
@ -2768,6 +2824,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
|
||||||
|
|
@ -2795,16 +2852,6 @@ function App() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!selectedPath && isGraphOpen && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => { void navigateToView({ type: 'chat', runId }) }}
|
|
||||||
className="titlebar-no-drag text-foreground self-center shrink-0"
|
|
||||||
>
|
|
||||||
Close Graph
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!selectedPath && !isGraphOpen && expandedFrom && (
|
{!selectedPath && !isGraphOpen && expandedFrom && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -2822,17 +2869,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 ? <Expand className="size-5" /> : <Shrink 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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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