mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
added tabs
This commit is contained in:
parent
9d4f25895e
commit
501c384c59
3 changed files with 250 additions and 15 deletions
|
|
@ -53,6 +53,7 @@ import { OnboardingModal } from '@/components/onboarding-modal'
|
||||||
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 { 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"
|
||||||
|
|
@ -661,6 +662,17 @@ 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
|
||||||
|
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
|
// 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())
|
||||||
|
|
@ -1495,6 +1507,8 @@ function App() {
|
||||||
})
|
})
|
||||||
currentRunId = run.id
|
currentRunId = run.id
|
||||||
setRunId(currentRunId)
|
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
|
isNewRun = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1607,7 +1621,97 @@ 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
|
||||||
|
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) => {
|
const handleChatInputSubmit = (text: string) => {
|
||||||
setIsChatSidebarOpen(true)
|
setIsChatSidebarOpen(true)
|
||||||
|
|
@ -2301,15 +2405,40 @@ function App() {
|
||||||
processingRunIds={processingRunIds}
|
processingRunIds={processingRunIds}
|
||||||
tasksActions={{
|
tasksActions={{
|
||||||
onNewChat: () => {
|
onNewChat: () => {
|
||||||
void navigateToView({ type: 'chat', runId: null })
|
handleNewChatTab()
|
||||||
},
|
},
|
||||||
onSelectRun: (runIdToLoad) => {
|
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 })
|
void navigateToView({ type: 'chat', runId: runIdToLoad })
|
||||||
},
|
},
|
||||||
onDeleteRun: async (runIdToDelete) => {
|
onDeleteRun: async (runIdToDelete) => {
|
||||||
try {
|
try {
|
||||||
await window.ipc.invoke('runs:delete', { runId: runIdToDelete })
|
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 })
|
void navigateToView({ type: 'chat', runId: null })
|
||||||
}
|
}
|
||||||
await loadRuns()
|
await loadRuns()
|
||||||
|
|
@ -2317,6 +2446,16 @@ function App() {
|
||||||
console.error('Failed to delete run:', err)
|
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) => {
|
onSelectBackgroundTask: (taskName) => {
|
||||||
void navigateToView({ type: 'task', name: taskName })
|
void navigateToView({ type: 'task', name: taskName })
|
||||||
},
|
},
|
||||||
|
|
@ -2431,6 +2570,14 @@ function App() {
|
||||||
) : (
|
) : (
|
||||||
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
|
<FileCardProvider onOpenKnowledgeFile={(path) => { navigateToFile(path) }}>
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<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]">
|
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||||
<ScrollPositionPreserver />
|
<ScrollPositionPreserver />
|
||||||
<ConversationContent className={conversationContentClassName}>
|
<ConversationContent className={conversationContentClassName}>
|
||||||
|
|
@ -2555,7 +2702,7 @@ function App() {
|
||||||
onNavigateForward={() => { void navigateForward() }}
|
onNavigateForward={() => { void navigateForward() }}
|
||||||
canNavigateBack={canNavigateBack}
|
canNavigateBack={canNavigateBack}
|
||||||
canNavigateForward={canNavigateForward}
|
canNavigateForward={canNavigateForward}
|
||||||
onNewChat={handleNewChat}
|
onNewChat={handleNewChatTab}
|
||||||
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
||||||
/>
|
/>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
|
||||||
71
apps/x/apps/renderer/src/components/chat-tab-bar.tsx
Normal file
71
apps/x/apps/renderer/src/components/chat-tab-bar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Copy,
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
|
@ -149,6 +150,7 @@ type TasksActions = {
|
||||||
onNewChat: () => void
|
onNewChat: () => void
|
||||||
onSelectRun: (runId: string) => void
|
onSelectRun: (runId: string) => void
|
||||||
onDeleteRun: (runId: string) => void
|
onDeleteRun: (runId: string) => void
|
||||||
|
onOpenInNewTab?: (runId: string) => void
|
||||||
onSelectBackgroundTask?: (taskName: string) => void
|
onSelectBackgroundTask?: (taskName: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1178,17 +1180,32 @@ function TasksSection({
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{!processingRunIds?.has(run.id) && (
|
{!processingRunIds?.has(run.id) && (
|
||||||
<button
|
<div className="shrink-0 hidden group-hover/chat-item:flex items-center gap-0.5">
|
||||||
type="button"
|
{actions?.onOpenInNewTab && (
|
||||||
className="shrink-0 hidden group-hover/chat-item:flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
|
<button
|
||||||
onClick={(e) => {
|
type="button"
|
||||||
e.stopPropagation()
|
className="flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
setPendingDeleteRunId(run.id)
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation()
|
||||||
aria-label="Delete chat"
|
actions.onOpenInNewTab!(run.id)
|
||||||
>
|
}}
|
||||||
<Trash2 className="size-3.5" />
|
aria-label="Open in new tab"
|
||||||
</button>
|
>
|
||||||
|
<ExternalLink className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setPendingDeleteRunId(run.id)
|
||||||
|
}}
|
||||||
|
aria-label="Delete chat"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue