added tabs

This commit is contained in:
Arjun 2026-02-17 11:35:03 +05:30 committed by tusharmagar
parent dee38a615f
commit c3603f32f2
3 changed files with 241 additions and 6 deletions

View file

@ -54,6 +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 { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
import { toast } from "sonner"
@ -662,6 +663,17 @@ function App() {
type RunListItem = { id: string; title?: string; createdAt: string; agentId: string }
const [runs, setRuns] = useState<RunListItem[]>([])
// Tab state
const [openTabs, setOpenTabs] = useState<ChatTab[]>([{ id: 'default-tab', runId: null }])
const [activeTabId, setActiveTabId] = useState('default-tab')
const tabIdCounterRef = useRef(0)
const newTabId = () => `tab-${++tabIdCounterRef.current}`
const getTabTitle = useCallback((tab: ChatTab) => {
if (!tab.runId) return 'New chat'
return runs.find(r => r.id === tab.runId)?.title || '(Untitled chat)'
}, [runs])
// Pending requests state
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<Map<string, z.infer<typeof ToolPermissionRequestEvent>>>(new Map())
const [pendingAskHumanRequests, setPendingAskHumanRequests] = useState<Map<string, z.infer<typeof AskHumanRequestEvent>>>(new Map())
@ -1499,6 +1511,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))
isNewRun = true
}
@ -1611,7 +1625,97 @@ 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])
// 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)
if (tab.runId) {
loadRun(tab.runId)
} else {
// Reset to new chat state
loadRunRequestIdRef.current += 1
setConversation([])
setCurrentAssistantMessage('')
setRunId(null)
setMessage('')
setModelUsage(null)
setIsProcessing(false)
setPendingPermissionRequests(new Map())
setPendingAskHumanRequests(new Map())
setAllPermissionRequests(new Map())
setPermissionResponses(new Map())
}
}, [openTabs, loadRun])
const closeTab = useCallback((tabId: string) => {
setOpenTabs(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) {
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())
}
}
return next
})
}, [activeTabId, loadRun])
const handleNewChatTab = useCallback(() => {
// If current tab already has a run, open a new tab
const activeTab = openTabs.find(t => t.id === activeTabId)
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()
}
// Ensure we're in chat view
if (selectedPath || isGraphOpen || selectedBackgroundTask) {
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
setSelectedBackgroundTask(null)
}
}, [openTabs, activeTabId, handleNewChat, selectedPath, isGraphOpen, selectedBackgroundTask])
const handleChatInputSubmit = (text: string) => {
setIsChatSidebarOpen(true)
@ -2317,15 +2421,40 @@ function App() {
processingRunIds={processingRunIds}
tasksActions={{
onNewChat: () => {
void navigateToView({ type: 'chat', runId: null })
handleNewChatTab()
},
onSelectRun: (runIdToLoad) => {
// If run is already open in a tab, switch to that tab
const existingTab = openTabs.find(t => t.runId === runIdToLoad)
if (existingTab) {
switchToTab(existingTab.id)
// Also ensure we navigate to chat view
if (selectedPath || isGraphOpen || selectedBackgroundTask) {
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
setSelectedBackgroundTask(null)
}
return
}
// Update current tab's runId
setOpenTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, runId: runIdToLoad } : t))
void navigateToView({ type: 'chat', runId: runIdToLoad })
},
onDeleteRun: async (runIdToDelete) => {
try {
await window.ipc.invoke('runs:delete', { runId: runIdToDelete })
if (runId === runIdToDelete) {
// Close any tab showing the deleted run
const tabForRun = openTabs.find(t => t.runId === runIdToDelete)
if (tabForRun) {
if (openTabs.length > 1) {
closeTab(tabForRun.id)
} else {
// Only one tab, reset it to new chat
setOpenTabs([{ id: tabForRun.id, runId: null }])
void navigateToView({ type: 'chat', runId: null })
}
} else if (runId === runIdToDelete) {
void navigateToView({ type: 'chat', runId: null })
}
await loadRuns()
@ -2333,6 +2462,16 @@ function App() {
console.error('Failed to delete run:', err)
}
},
onOpenInNewTab: (targetRunId) => {
openInNewTab(targetRunId)
// Ensure we're in chat view
if (selectedPath || isGraphOpen || selectedBackgroundTask) {
setSelectedPath(null)
setIsGraphOpen(false)
setExpandedFrom(null)
setSelectedBackgroundTask(null)
}
},
onSelectBackgroundTask: (taskName) => {
void navigateToView({ type: 'task', name: taskName })
},
@ -2455,6 +2594,14 @@ function App() {
) : (
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
<div className="flex min-h-0 flex-1 flex-col">
<ChatTabBar
tabs={openTabs}
activeTabId={activeTabId}
getTabTitle={getTabTitle}
processingRunIds={processingRunIds}
onSwitchTab={switchToTab}
onCloseTab={closeTab}
/>
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<ScrollPositionPreserver />
<ConversationContent className={conversationContentClassName}>
@ -2579,7 +2726,7 @@ function App() {
onNavigateForward={() => { void navigateForward() }}
canNavigateBack={canNavigateBack}
canNavigateForward={canNavigateForward}
onNewChat={handleNewChat}
onNewChat={handleNewChatTab}
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
/>
</SidebarProvider>

View file

@ -0,0 +1,71 @@
import * as React from "react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
export type ChatTab = {
id: string
runId: string | null
}
interface ChatTabBarProps {
tabs: ChatTab[]
activeTabId: string
getTabTitle: (tab: ChatTab) => string
processingRunIds: Set<string>
onSwitchTab: (tabId: string) => void
onCloseTab: (tabId: string) => void
}
export function ChatTabBar({
tabs,
activeTabId,
getTabTitle,
processingRunIds,
onSwitchTab,
onCloseTab,
}: ChatTabBarProps) {
if (tabs.length <= 1) return null
return (
<div className="flex items-center gap-0 border-b border-border bg-sidebar overflow-x-auto px-1 shrink-0">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId
const isProcessing = tab.runId ? processingRunIds.has(tab.runId) : false
const title = getTabTitle(tab)
return (
<button
key={tab.id}
type="button"
onClick={() => onSwitchTab(tab.id)}
className={cn(
"group/tab relative flex items-center gap-1.5 px-3 py-1.5 text-xs max-w-[180px] min-w-[80px] transition-colors",
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
>
{isProcessing && (
<span className="size-1.5 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
)}
<span className="truncate flex-1 text-left">{title}</span>
<span
role="button"
className={cn(
"shrink-0 flex items-center justify-center rounded-sm p-0.5 hover:bg-foreground/10 transition-colors",
isActive ? "opacity-60 hover:opacity-100" : "opacity-0 group-hover/tab:opacity-60 hover:!opacity-100"
)}
onClick={(e) => {
e.stopPropagation()
onCloseTab(tab.id)
}}
aria-label="Close tab"
>
<X className="size-3" />
</span>
</button>
)
})}
</div>
)
}

View file

@ -8,6 +8,7 @@ import {
ChevronsDownUp,
ChevronsUpDown,
Copy,
ExternalLink,
FilePlus,
FolderPlus,
AlertTriangle,
@ -149,6 +150,7 @@ type TasksActions = {
onNewChat: () => void
onSelectRun: (runId: string) => void
onDeleteRun: (runId: string) => void
onOpenInNewTab?: (runId: string) => void
onSelectBackgroundTask?: (taskName: string) => void
}
@ -1162,7 +1164,7 @@ function TasksSection({
</div>
<SidebarMenu>
{runs.map((run) => (
<SidebarMenuItem key={run.id}>
<SidebarMenuItem key={run.id} className="group/chat-item">
<ContextMenu>
<ContextMenuTrigger asChild>
<SidebarMenuButton
@ -1175,10 +1177,25 @@ function TasksSection({
) : null}
<span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span>
{run.createdAt ? (
<span className="shrink-0 text-[10px] text-muted-foreground">
<span className={`shrink-0 text-[10px] text-muted-foreground${processingRunIds?.has(run.id) ? '' : ' group-hover/chat-item:hidden'}`}>
{formatRunTime(run.createdAt)}
</span>
) : null}
{!processingRunIds?.has(run.id) && actions?.onOpenInNewTab && (
<div className="shrink-0 hidden group-hover/chat-item:flex items-center gap-0.5">
<button
type="button"
className="flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => {
e.stopPropagation()
actions.onOpenInNewTab!(run.id)
}}
aria-label="Open in new tab"
>
<ExternalLink className="size-3.5" />
</button>
</div>
)}
</div>
</SidebarMenuButton>
</ContextMenuTrigger>