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:
tusharmagar 2026-02-19 15:30:26 +05:30
parent 06444e5db2
commit f61404cbcd
2 changed files with 88 additions and 43 deletions

View file

@ -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>
)} )}

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"