added tabs to notes

This commit is contained in:
Arjun 2026-02-17 17:02:19 +05:30 committed by tusharmagar
parent 8773a8ce64
commit 4f3453a65b
3 changed files with 197 additions and 113 deletions

View file

@ -54,7 +54,7 @@ import { SearchDialog } from '@/components/search-dialog'
import { BackgroundTaskDetail } from '@/components/background-task-detail' import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { FileCardProvider } from '@/contexts/file-card-context' import { FileCardProvider } from '@/contexts/file-card-context'
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
import { ChatTabBar, type ChatTab } from '@/components/chat-tab-bar' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js' import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
import { toast } from "sonner" import { toast } from "sonner"
@ -663,17 +663,31 @@ function App() {
type RunListItem = { id: string; title?: string; createdAt: string; agentId: string } type RunListItem = { id: string; title?: string; createdAt: string; agentId: string }
const [runs, setRuns] = useState<RunListItem[]>([]) const [runs, setRuns] = useState<RunListItem[]>([])
// Tab state // Chat tab state
const [openTabs, setOpenTabs] = useState<ChatTab[]>([{ id: 'default-tab', runId: null }]) const [chatTabs, setChatTabs] = useState<ChatTab[]>([{ id: 'default-chat-tab', runId: null }])
const [activeTabId, setActiveTabId] = useState('default-tab') const [activeChatTabId, setActiveChatTabId] = useState('default-chat-tab')
const tabIdCounterRef = useRef(0) const chatTabIdCounterRef = useRef(0)
const newTabId = () => `tab-${++tabIdCounterRef.current}` const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}`
const getTabTitle = useCallback((tab: ChatTab) => { const getChatTabTitle = useCallback((tab: ChatTab) => {
if (!tab.runId) return 'New chat' if (!tab.runId) return 'New chat'
return runs.find(r => r.id === tab.runId)?.title || '(Untitled chat)' return runs.find(r => r.id === tab.runId)?.title || '(Untitled chat)'
}, [runs]) }, [runs])
const isChatTabProcessing = useCallback((tab: ChatTab) => {
return tab.runId ? processingRunIds.has(tab.runId) : false
}, [processingRunIds])
// File tab state
const [fileTabs, setFileTabs] = useState<FileTab[]>([])
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null)
const fileTabIdCounterRef = useRef(0)
const newFileTabId = () => `file-tab-${++fileTabIdCounterRef.current}`
const getFileTabTitle = useCallback((tab: FileTab) => {
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
}, [])
// Pending requests state // Pending requests state
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map()) const [pendingPermissionRequests, setPendingPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
const [pendingAskHumanRequests, setPendingAskHumanRequests] = useState<Map<string, z.infer<typeof AskHumanRequestEvent>>>(new Map()) const [pendingAskHumanRequests, setPendingAskHumanRequests] = useState<Map<string, z.infer<typeof AskHumanRequestEvent>>>(new Map())
@ -1511,8 +1525,8 @@ function App() {
}) })
currentRunId = run.id currentRunId = run.id
setRunId(currentRunId) setRunId(currentRunId)
// Update active tab's runId to the new run // Update active chat tab's runId to the new run
setOpenTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, runId: currentRunId } : t)) setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: currentRunId } : t))
isNewRun = true isNewRun = true
} }
@ -1625,33 +1639,15 @@ function App() {
setAllPermissionRequests(new Map()) setAllPermissionRequests(new Map())
setPermissionResponses(new Map()) setPermissionResponses(new Map())
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
// Update active tab's runId to null // Update active chat tab's runId to null
setOpenTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, runId: null } : t)) setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
}, [activeTabId]) }, [activeChatTabId])
// Tab operations // Chat tab operations
const openInNewTab = useCallback((targetRunId: string) => { const applyChatTab = useCallback((tab: ChatTab) => {
// If already open in a tab, just switch to it
const existingTab = openTabs.find(t => t.runId === targetRunId)
if (existingTab) {
setActiveTabId(existingTab.id)
loadRun(targetRunId)
return
}
const id = newTabId()
setOpenTabs(prev => [...prev, { id, runId: targetRunId }])
setActiveTabId(id)
loadRun(targetRunId)
}, [openTabs, loadRun])
const switchToTab = useCallback((tabId: string) => {
const tab = openTabs.find(t => t.id === tabId)
if (!tab) return
setActiveTabId(tabId)
if (tab.runId) { if (tab.runId) {
loadRun(tab.runId) loadRun(tab.runId)
} else { } else {
// Reset to new chat state
loadRunRequestIdRef.current += 1 loadRunRequestIdRef.current += 1
setConversation([]) setConversation([])
setCurrentAssistantMessage('') setCurrentAssistantMessage('')
@ -1664,50 +1660,93 @@ function App() {
setAllPermissionRequests(new Map()) setAllPermissionRequests(new Map())
setPermissionResponses(new Map()) setPermissionResponses(new Map())
} }
}, [openTabs, loadRun]) }, [loadRun])
const closeTab = useCallback((tabId: string) => { const openChatInNewTab = useCallback((targetRunId: string) => {
setOpenTabs(prev => { const existingTab = chatTabs.find(t => t.runId === targetRunId)
if (existingTab) {
setActiveChatTabId(existingTab.id)
loadRun(targetRunId)
return
}
const id = newChatTabId()
setChatTabs(prev => [...prev, { id, runId: targetRunId }])
setActiveChatTabId(id)
loadRun(targetRunId)
}, [chatTabs, loadRun])
const switchChatTab = useCallback((tabId: string) => {
const tab = chatTabs.find(t => t.id === tabId)
if (!tab) return
setActiveChatTabId(tabId)
applyChatTab(tab)
}, [chatTabs, applyChatTab])
const closeChatTab = useCallback((tabId: string) => {
setChatTabs(prev => {
if (prev.length <= 1) return prev if (prev.length <= 1) return prev
const idx = prev.findIndex(t => t.id === tabId) const idx = prev.findIndex(t => t.id === tabId)
const next = prev.filter(t => t.id !== tabId) const next = prev.filter(t => t.id !== tabId)
// If closing the active tab, switch to adjacent if (tabId === activeChatTabId && next.length > 0) {
if (tabId === activeTabId && 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]
setActiveTabId(newActiveTab.id) setActiveChatTabId(newActiveTab.id)
if (newActiveTab.runId) { applyChatTab(newActiveTab)
loadRun(newActiveTab.runId)
} else {
loadRunRequestIdRef.current += 1
setConversation([])
setCurrentAssistantMessage('')
setRunId(null)
setMessage('')
setModelUsage(null)
setIsProcessing(false)
setPendingPermissionRequests(new Map())
setPendingAskHumanRequests(new Map())
setAllPermissionRequests(new Map())
setPermissionResponses(new Map())
}
} }
return next return next
}) })
}, [activeTabId, loadRun]) }, [activeChatTabId, applyChatTab])
// File tab operations
const openFileInNewTab = useCallback((path: string) => {
const existingTab = fileTabs.find(t => t.path === path)
if (existingTab) {
setActiveFileTabId(existingTab.id)
setSelectedPath(path)
return
}
const id = newFileTabId()
setFileTabs(prev => [...prev, { id, path }])
setActiveFileTabId(id)
setSelectedPath(path)
}, [fileTabs])
const switchFileTab = useCallback((tabId: string) => {
const tab = fileTabs.find(t => t.id === tabId)
if (!tab) return
setActiveFileTabId(tabId)
setSelectedPath(tab.path)
}, [fileTabs])
const closeFileTab = useCallback((tabId: string) => {
setFileTabs(prev => {
if (prev.length <= 1) {
// Last file tab - close it and go back to chat
setActiveFileTabId(null)
setSelectedPath(null)
return []
}
const idx = prev.findIndex(t => t.id === tabId)
const next = prev.filter(t => t.id !== tabId)
if (tabId === activeFileTabId && next.length > 0) {
const newIdx = Math.min(idx, next.length - 1)
const newActiveTab = next[newIdx]
setActiveFileTabId(newActiveTab.id)
setSelectedPath(newActiveTab.path)
}
return next
})
}, [activeFileTabId])
const handleNewChatTab = useCallback(() => { const handleNewChatTab = useCallback(() => {
// If current tab already has a run, open a new tab // If current chat tab already has a run, open a new tab
const activeTab = openTabs.find(t => t.id === activeTabId) const activeTab = chatTabs.find(t => t.id === activeChatTabId)
if (activeTab?.runId) { if (activeTab?.runId) {
const id = newTabId() const id = newChatTabId()
setOpenTabs(prev => [...prev, { id, runId: null }]) setChatTabs(prev => [...prev, { id, runId: null }])
setActiveTabId(id) setActiveChatTabId(id)
handleNewChat()
} else {
// Current tab is already blank, just focus it
handleNewChat()
} }
handleNewChat()
// Ensure we're in chat view // Ensure we're in chat view
if (selectedPath || isGraphOpen || selectedBackgroundTask) { if (selectedPath || isGraphOpen || selectedBackgroundTask) {
setSelectedPath(null) setSelectedPath(null)
@ -1715,7 +1754,7 @@ function App() {
setExpandedFrom(null) setExpandedFrom(null)
setSelectedBackgroundTask(null) setSelectedBackgroundTask(null)
} }
}, [openTabs, activeTabId, handleNewChat, selectedPath, isGraphOpen, selectedBackgroundTask]) }, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, selectedBackgroundTask])
const handleChatInputSubmit = (text: string) => { const handleChatInputSubmit = (text: string) => {
setIsChatSidebarOpen(true) setIsChatSidebarOpen(true)
@ -1866,8 +1905,22 @@ 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]) }, [navigateToView, fileTabs, activeFileTabId, switchFileTab])
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,7 +2152,13 @@ function App() {
remove: async (path: string) => { remove: async (path: string) => {
try { try {
await window.ipc.invoke('workspace:remove', { path, opts: { trash: true } }) await window.ipc.invoke('workspace:remove', { path, opts: { trash: true } })
if (selectedPath === path) setSelectedPath(null) // Close any file tab showing the deleted file
const tabForFile = fileTabs.find(t => t.path === path)
if (tabForFile) {
closeFileTab(tabForFile.id)
} else if (selectedPath === path) {
setSelectedPath(null)
}
} catch (err) { } catch (err) {
console.error('Failed to remove:', err) console.error('Failed to remove:', err)
throw err throw err
@ -2109,7 +2168,10 @@ function App() {
const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path
navigator.clipboard.writeText(fullPath) navigator.clipboard.writeText(fullPath)
}, },
}), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView]) onOpenInNewTab: (path: string) => {
openFileInNewTab(path)
},
}), [tree, selectedPath, workspaceRoot, collectDirPaths, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab])
// 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) => {
@ -2424,42 +2486,30 @@ function App() {
handleNewChatTab() handleNewChatTab()
}, },
onSelectRun: (runIdToLoad) => { onSelectRun: (runIdToLoad) => {
// If already open in a tab, switch to that tab // If already open in a chat tab, switch to it
const existingTab = openTabs.find(t => t.runId === runIdToLoad) const existingTab = chatTabs.find(t => t.runId === runIdToLoad)
if (existingTab) { if (existingTab) {
switchToTab(existingTab.id) switchChatTab(existingTab.id)
if (selectedPath || isGraphOpen || selectedBackgroundTask) {
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
setSelectedBackgroundTask(null)
}
return return
} }
// Navigate current tab to this run // Navigate current chat tab to this run
setOpenTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, runId: runIdToLoad } : t)) setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
void navigateToView({ type: 'chat', runId: runIdToLoad }) void navigateToView({ type: 'chat', runId: runIdToLoad })
}, },
onOpenInNewTab: (targetRunId) => { onOpenInNewTab: (targetRunId) => {
openInNewTab(targetRunId) openChatInNewTab(targetRunId)
if (selectedPath || isGraphOpen || selectedBackgroundTask) {
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
setSelectedBackgroundTask(null)
}
}, },
onDeleteRun: async (runIdToDelete) => { onDeleteRun: async (runIdToDelete) => {
try { try {
await window.ipc.invoke('runs:delete', { runId: runIdToDelete }) await window.ipc.invoke('runs:delete', { runId: runIdToDelete })
// Close any tab showing the deleted run // Close any chat tab showing the deleted run
const tabForRun = openTabs.find(t => t.runId === runIdToDelete) const tabForRun = chatTabs.find(t => t.runId === runIdToDelete)
if (tabForRun) { if (tabForRun) {
if (openTabs.length > 1) { if (chatTabs.length > 1) {
closeTab(tabForRun.id) closeChatTab(tabForRun.id)
} else { } else {
// Only one tab, reset it to new chat // Only one tab, reset it to new chat
setOpenTabs([{ id: tabForRun.id, runId: null }]) setChatTabs([{ id: tabForRun.id, runId: null }])
void navigateToView({ type: 'chat', runId: null }) void navigateToView({ type: 'chat', runId: null })
} }
} else if (runId === runIdToDelete) { } else if (runId === runIdToDelete) {
@ -2486,14 +2536,24 @@ function App() {
canNavigateForward={canNavigateForward} canNavigateForward={canNavigateForward}
collapsedLeftPaddingPx={collapsedLeftPaddingPx} collapsedLeftPaddingPx={collapsedLeftPaddingPx}
> >
{openTabs.length > 1 ? ( {selectedPath && fileTabs.length > 1 ? (
<ChatTabBar <TabBar
tabs={openTabs} tabs={fileTabs}
activeTabId={activeTabId} activeTabId={activeFileTabId ?? ''}
getTabTitle={getTabTitle} getTabTitle={getFileTabTitle}
processingRunIds={processingRunIds} getTabId={(t) => t.id}
onSwitchTab={switchToTab} onSwitchTab={switchFileTab}
onCloseTab={closeTab} onCloseTab={closeFileTab}
/>
) : !selectedPath && !isGraphOpen && !selectedBackgroundTask && chatTabs.length > 1 ? (
<TabBar
tabs={chatTabs}
activeTabId={activeChatTabId}
getTabTitle={getChatTabTitle}
getTabId={(t) => t.id}
isProcessing={isChatTabProcessing}
onSwitchTab={switchChatTab}
onCloseTab={closeChatTab}
/> />
) : ( ) : (
<span className="text-sm font-medium text-muted-foreground flex-1 min-w-0 truncate"> <span className="text-sm font-medium text-muted-foreground flex-1 min-w-0 truncate">

View file

@ -106,6 +106,7 @@ type KnowledgeActions = {
rename: (path: string, newName: string, isDir: boolean) => Promise<void> rename: (path: string, newName: string, isDir: boolean) => Promise<void>
remove: (path: string) => Promise<void> remove: (path: string) => Promise<void>
copyPath: (path: string) => void copyPath: (path: string) => void
onOpenInNewTab?: (path: string) => void
} }
type RunListItem = { type RunListItem = {
@ -1035,12 +1036,27 @@ function Tree({
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<SidebarMenuItem> <SidebarMenuItem className="group/file-item">
<SidebarMenuButton <SidebarMenuButton
isActive={isSelected} isActive={isSelected}
onClick={() => onSelect(item.path, item.kind)} onClick={() => onSelect(item.path, item.kind)}
> >
<span>{item.name}</span> <div className="flex w-full items-center gap-1 min-w-0">
<span className="min-w-0 flex-1 truncate">{item.name}</span>
{actions.onOpenInNewTab && (
<button
type="button"
className="shrink-0 hidden group-hover/file-item:flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => {
e.stopPropagation()
actions.onOpenInNewTab!(item.path)
}}
aria-label="Open in new tab"
>
<ExternalLink className="size-3.5" />
</button>
)}
</div>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</ContextMenuTrigger> </ContextMenuTrigger>

View file

@ -7,35 +7,43 @@ export type ChatTab = {
runId: string | null runId: string | null
} }
interface ChatTabBarProps { export type FileTab = {
tabs: ChatTab[] id: string
path: string
}
interface TabBarProps<T> {
tabs: T[]
activeTabId: string activeTabId: string
getTabTitle: (tab: ChatTab) => string getTabTitle: (tab: T) => string
processingRunIds: Set<string> getTabId: (tab: T) => string
isProcessing?: (tab: T) => boolean
onSwitchTab: (tabId: string) => void onSwitchTab: (tabId: string) => void
onCloseTab: (tabId: string) => void onCloseTab: (tabId: string) => void
} }
export function ChatTabBar({ export function TabBar<T>({
tabs, tabs,
activeTabId, activeTabId,
getTabTitle, getTabTitle,
processingRunIds, getTabId,
isProcessing,
onSwitchTab, onSwitchTab,
onCloseTab, onCloseTab,
}: ChatTabBarProps) { }: TabBarProps<T>) {
return ( return (
<div className="titlebar-no-drag flex flex-1 items-center gap-0 overflow-x-auto min-w-0"> <div className="titlebar-no-drag flex flex-1 items-center gap-0 overflow-x-auto min-w-0">
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = tab.id === activeTabId const tabId = getTabId(tab)
const isProcessing = tab.runId ? processingRunIds.has(tab.runId) : false const isActive = tabId === activeTabId
const processing = isProcessing?.(tab) ?? false
const title = getTabTitle(tab) const title = getTabTitle(tab)
return ( return (
<button <button
key={tab.id} key={tabId}
type="button" type="button"
onClick={() => onSwitchTab(tab.id)} onClick={() => onSwitchTab(tabId)}
className={cn( className={cn(
"group/tab relative flex items-center gap-1.5 px-3 h-full text-xs max-w-[180px] min-w-[80px] transition-colors", "group/tab relative flex items-center gap-1.5 px-3 h-full text-xs max-w-[180px] min-w-[80px] transition-colors",
isActive isActive
@ -43,7 +51,7 @@ export function ChatTabBar({
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)} )}
> >
{isProcessing && ( {processing && (
<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>
@ -55,7 +63,7 @@ export function ChatTabBar({
)} )}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onCloseTab(tab.id) onCloseTab(tabId)
}} }}
aria-label="Close tab" aria-label="Close tab"
> >