From 4f3453a65bbfa9771d31b8868b7f57bd156972a6 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:02:19 +0530 Subject: [PATCH] added tabs to notes --- apps/x/apps/renderer/src/App.tsx | 256 +++++++++++------- .../src/components/sidebar-content.tsx | 20 +- .../{chat-tab-bar.tsx => tab-bar.tsx} | 34 ++- 3 files changed, 197 insertions(+), 113 deletions(-) rename apps/x/apps/renderer/src/components/{chat-tab-bar.tsx => tab-bar.tsx} (75%) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index a88ec494..f640b76a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -54,7 +54,7 @@ import { SearchDialog } from '@/components/search-dialog' import { BackgroundTaskDetail } from '@/components/background-task-detail' import { FileCardProvider } from '@/contexts/file-card-context' 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 { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js' import { toast } from "sonner" @@ -663,17 +663,31 @@ function App() { type RunListItem = { id: string; title?: string; createdAt: string; agentId: string } const [runs, setRuns] = useState([]) - // Tab state - const [openTabs, setOpenTabs] = useState([{ id: 'default-tab', runId: null }]) - const [activeTabId, setActiveTabId] = useState('default-tab') - const tabIdCounterRef = useRef(0) - const newTabId = () => `tab-${++tabIdCounterRef.current}` + // Chat tab state + const [chatTabs, setChatTabs] = useState([{ id: 'default-chat-tab', runId: null }]) + const [activeChatTabId, setActiveChatTabId] = useState('default-chat-tab') + const chatTabIdCounterRef = useRef(0) + const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}` - const getTabTitle = useCallback((tab: ChatTab) => { + const getChatTabTitle = useCallback((tab: ChatTab) => { if (!tab.runId) return 'New chat' return runs.find(r => r.id === tab.runId)?.title || '(Untitled chat)' }, [runs]) + const isChatTabProcessing = useCallback((tab: ChatTab) => { + return tab.runId ? processingRunIds.has(tab.runId) : false + }, [processingRunIds]) + + // File tab state + const [fileTabs, setFileTabs] = useState([]) + const [activeFileTabId, setActiveFileTabId] = useState(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 const [pendingPermissionRequests, setPendingPermissionRequests] = useState>>(new Map()) const [pendingAskHumanRequests, setPendingAskHumanRequests] = useState>>(new Map()) @@ -1511,8 +1525,8 @@ function App() { }) currentRunId = run.id setRunId(currentRunId) - // Update active tab's runId to the new run - setOpenTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, runId: currentRunId } : t)) + // Update active chat tab's runId to the new run + setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: currentRunId } : t)) isNewRun = true } @@ -1625,33 +1639,15 @@ function App() { setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) setSelectedBackgroundTask(null) - // Update active tab's runId to null - setOpenTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, runId: null } : t)) - }, [activeTabId]) + // Update active chat tab's runId to null + setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) + }, [activeChatTabId]) - // Tab operations - const openInNewTab = useCallback((targetRunId: string) => { - // 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) + // Chat tab operations + const applyChatTab = useCallback((tab: ChatTab) => { if (tab.runId) { loadRun(tab.runId) } else { - // Reset to new chat state loadRunRequestIdRef.current += 1 setConversation([]) setCurrentAssistantMessage('') @@ -1664,50 +1660,93 @@ function App() { setAllPermissionRequests(new Map()) setPermissionResponses(new Map()) } - }, [openTabs, loadRun]) + }, [loadRun]) - const closeTab = useCallback((tabId: string) => { - setOpenTabs(prev => { + const openChatInNewTab = useCallback((targetRunId: string) => { + 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 const idx = prev.findIndex(t => t.id === tabId) const next = prev.filter(t => t.id !== tabId) - // If closing the active tab, switch to adjacent - if (tabId === activeTabId && next.length > 0) { + if (tabId === activeChatTabId && next.length > 0) { const newIdx = Math.min(idx, next.length - 1) const newActiveTab = next[newIdx] - setActiveTabId(newActiveTab.id) - if (newActiveTab.runId) { - 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()) - } + setActiveChatTabId(newActiveTab.id) + applyChatTab(newActiveTab) } 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(() => { - // If current tab already has a run, open a new tab - const activeTab = openTabs.find(t => t.id === activeTabId) + // If current chat tab already has a run, open a new tab + const activeTab = chatTabs.find(t => t.id === activeChatTabId) if (activeTab?.runId) { - const id = newTabId() - setOpenTabs(prev => [...prev, { id, runId: null }]) - setActiveTabId(id) - handleNewChat() - } else { - // Current tab is already blank, just focus it - handleNewChat() + const id = newChatTabId() + setChatTabs(prev => [...prev, { id, runId: null }]) + setActiveChatTabId(id) } + handleNewChat() // Ensure we're in chat view if (selectedPath || isGraphOpen || selectedBackgroundTask) { setSelectedPath(null) @@ -1715,7 +1754,7 @@ function App() { setExpandedFrom(null) setSelectedBackgroundTask(null) } - }, [openTabs, activeTabId, handleNewChat, selectedPath, isGraphOpen, selectedBackgroundTask]) + }, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, selectedBackgroundTask]) const handleChatInputSubmit = (text: string) => { setIsChatSidebarOpen(true) @@ -1866,8 +1905,22 @@ function App() { }, [viewHistory.forward, currentViewState]) 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 }) - }, [navigateToView]) + }, [navigateToView, fileTabs, activeFileTabId, switchFileTab]) const navigateToFullScreenChat = useCallback(() => { // Only treat this as navigation when coming from another view @@ -2099,7 +2152,13 @@ function App() { remove: async (path: string) => { try { 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) { console.error('Failed to remove:', err) throw err @@ -2109,7 +2168,10 @@ function App() { const fullPath = workspaceRoot ? `${workspaceRoot}/${path}` : path 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 const handleVoiceNoteCreated = useCallback(async (notePath: string) => { @@ -2424,42 +2486,30 @@ function App() { handleNewChatTab() }, onSelectRun: (runIdToLoad) => { - // If already open in a tab, switch to that tab - const existingTab = openTabs.find(t => t.runId === runIdToLoad) + // If already open in a chat tab, switch to it + const existingTab = chatTabs.find(t => t.runId === runIdToLoad) if (existingTab) { - switchToTab(existingTab.id) - if (selectedPath || isGraphOpen || selectedBackgroundTask) { - setSelectedPath(null) - setIsGraphOpen(false) - setExpandedFrom(null) - setSelectedBackgroundTask(null) - } + switchChatTab(existingTab.id) return } - // Navigate current tab to this run - setOpenTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, runId: runIdToLoad } : t)) + // Navigate current chat tab to this run + setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) void navigateToView({ type: 'chat', runId: runIdToLoad }) }, onOpenInNewTab: (targetRunId) => { - openInNewTab(targetRunId) - if (selectedPath || isGraphOpen || selectedBackgroundTask) { - setSelectedPath(null) - setIsGraphOpen(false) - setExpandedFrom(null) - setSelectedBackgroundTask(null) - } + openChatInNewTab(targetRunId) }, onDeleteRun: async (runIdToDelete) => { try { await window.ipc.invoke('runs:delete', { runId: runIdToDelete }) - // Close any tab showing the deleted run - const tabForRun = openTabs.find(t => t.runId === runIdToDelete) + // Close any chat tab showing the deleted run + const tabForRun = chatTabs.find(t => t.runId === runIdToDelete) if (tabForRun) { - if (openTabs.length > 1) { - closeTab(tabForRun.id) + if (chatTabs.length > 1) { + closeChatTab(tabForRun.id) } else { // 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 }) } } else if (runId === runIdToDelete) { @@ -2486,14 +2536,24 @@ function App() { canNavigateForward={canNavigateForward} collapsedLeftPaddingPx={collapsedLeftPaddingPx} > - {openTabs.length > 1 ? ( - 1 ? ( + t.id} + onSwitchTab={switchFileTab} + onCloseTab={closeFileTab} + /> + ) : !selectedPath && !isGraphOpen && !selectedBackgroundTask && chatTabs.length > 1 ? ( + t.id} + isProcessing={isChatTabProcessing} + onSwitchTab={switchChatTab} + onCloseTab={closeChatTab} /> ) : ( diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 2b010fe0..130a6107 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -106,6 +106,7 @@ type KnowledgeActions = { rename: (path: string, newName: string, isDir: boolean) => Promise remove: (path: string) => Promise copyPath: (path: string) => void + onOpenInNewTab?: (path: string) => void } type RunListItem = { @@ -1035,12 +1036,27 @@ function Tree({ return ( - + onSelect(item.path, item.kind)} > - {item.name} +
+ {item.name} + {actions.onOpenInNewTab && ( + + )} +
diff --git a/apps/x/apps/renderer/src/components/chat-tab-bar.tsx b/apps/x/apps/renderer/src/components/tab-bar.tsx similarity index 75% rename from apps/x/apps/renderer/src/components/chat-tab-bar.tsx rename to apps/x/apps/renderer/src/components/tab-bar.tsx index 0c150207..4c307cf6 100644 --- a/apps/x/apps/renderer/src/components/chat-tab-bar.tsx +++ b/apps/x/apps/renderer/src/components/tab-bar.tsx @@ -7,35 +7,43 @@ export type ChatTab = { runId: string | null } -interface ChatTabBarProps { - tabs: ChatTab[] +export type FileTab = { + id: string + path: string +} + +interface TabBarProps { + tabs: T[] activeTabId: string - getTabTitle: (tab: ChatTab) => string - processingRunIds: Set + getTabTitle: (tab: T) => string + getTabId: (tab: T) => string + isProcessing?: (tab: T) => boolean onSwitchTab: (tabId: string) => void onCloseTab: (tabId: string) => void } -export function ChatTabBar({ +export function TabBar({ tabs, activeTabId, getTabTitle, - processingRunIds, + getTabId, + isProcessing, onSwitchTab, onCloseTab, -}: ChatTabBarProps) { +}: TabBarProps) { return (
{tabs.map((tab) => { - const isActive = tab.id === activeTabId - const isProcessing = tab.runId ? processingRunIds.has(tab.runId) : false + const tabId = getTabId(tab) + const isActive = tabId === activeTabId + const processing = isProcessing?.(tab) ?? false const title = getTabTitle(tab) return (