mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-27 20:29:44 +02:00
Browser2 (#495)
Add tabbed embedded browser and assistant browser control
This commit is contained in:
parent
e2c13f0f6f
commit
7dbfcb72f4
23 changed files with 2893 additions and 59 deletions
|
|
@ -5,7 +5,7 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
|||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||
import './App.css'
|
||||
import z from 'zod';
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon } from 'lucide-react';
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon, RadioIcon, SquareIcon, Globe } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
||||
import { ChatSidebar } from './components/chat-sidebar';
|
||||
|
|
@ -57,6 +57,7 @@ import { OnboardingModal } from '@/components/onboarding'
|
|||
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
|
||||
import { TrackModal } from '@/components/track-modal'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
import { BrowserPane } from '@/components/browser-pane/BrowserPane'
|
||||
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
|
|
@ -449,23 +450,36 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
|||
|
||||
/** Sidebar toggle + utility buttons (fixed position, top-left) */
|
||||
function FixedSidebarToggle({
|
||||
onNavigateBack,
|
||||
onNavigateForward,
|
||||
canNavigateBack,
|
||||
canNavigateForward,
|
||||
onNewChat,
|
||||
onOpenSearch,
|
||||
meetingState,
|
||||
meetingSummarizing,
|
||||
meetingAvailable,
|
||||
onToggleMeeting,
|
||||
isBrowserOpen,
|
||||
onToggleBrowser,
|
||||
leftInsetPx,
|
||||
}: {
|
||||
onNavigateBack: () => void
|
||||
onNavigateForward: () => void
|
||||
canNavigateBack: boolean
|
||||
canNavigateForward: boolean
|
||||
onNewChat: () => void
|
||||
onOpenSearch: () => void
|
||||
meetingState: MeetingTranscriptionState
|
||||
meetingSummarizing: boolean
|
||||
meetingAvailable: boolean
|
||||
onToggleMeeting: () => void
|
||||
isBrowserOpen: boolean
|
||||
onToggleBrowser: () => void
|
||||
leftInsetPx: number
|
||||
}) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar, state } = useSidebar()
|
||||
const isCollapsed = state === "collapsed"
|
||||
return (
|
||||
<div className="fixed left-0 top-0 z-50 flex h-10 items-center" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<div aria-hidden="true" className="h-10 shrink-0" style={{ width: leftInsetPx }} />
|
||||
|
|
@ -528,6 +542,49 @@ function FixedSidebarToggle({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleBrowser}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md transition-colors",
|
||||
isBrowserOpen
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
aria-label={isBrowserOpen ? "Close browser" : "Open browser"}
|
||||
>
|
||||
<Globe className="size-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{isBrowserOpen ? 'Close browser' : 'Open browser'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Back / Forward navigation */}
|
||||
{isCollapsed && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateBack}
|
||||
disabled={!canNavigateBack}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateForward}
|
||||
disabled={!canNavigateForward}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label="Go forward"
|
||||
>
|
||||
<ChevronRightIcon className="size-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -606,6 +663,7 @@ function App() {
|
|||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||||
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
|
||||
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
||||
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
|
||||
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
|
||||
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||
|
|
@ -2714,6 +2772,24 @@ function App() {
|
|||
setIsChatSidebarOpen(prev => !prev)
|
||||
}, [])
|
||||
|
||||
// Browser is an overlay on the middle pane: opening it forces the chat
|
||||
// sidebar to be visible on the right; closing it restores whatever the
|
||||
// middle pane was showing previously (file/graph/task/chat).
|
||||
const handleToggleBrowser = useCallback(() => {
|
||||
setIsBrowserOpen(prev => {
|
||||
const next = !prev
|
||||
if (next) {
|
||||
setIsChatSidebarOpen(true)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleCloseBrowser = useCallback(() => {
|
||||
setIsBrowserOpen(false)
|
||||
}, [])
|
||||
|
||||
const toggleRightPaneMaximize = useCallback(() => {
|
||||
setIsChatSidebarOpen(true)
|
||||
setIsRightPaneMaximized(prev => !prev)
|
||||
|
|
@ -2797,6 +2873,9 @@ function App() {
|
|||
case 'file':
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsGraphOpen(false)
|
||||
// Navigating to a file dismisses the browser overlay so the file is
|
||||
// visible in the middle pane.
|
||||
setIsBrowserOpen(false)
|
||||
setExpandedFrom(null)
|
||||
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
||||
// Only exit chat-only maximize, because that would hide the selected file.
|
||||
|
|
@ -2809,6 +2888,7 @@ function App() {
|
|||
case 'graph':
|
||||
setSelectedBackgroundTask(null)
|
||||
setSelectedPath(null)
|
||||
setIsBrowserOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsGraphOpen(true)
|
||||
ensureGraphFileTab()
|
||||
|
|
@ -2819,6 +2899,7 @@ function App() {
|
|||
case 'task':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(view.name)
|
||||
|
|
@ -2826,6 +2907,8 @@ function App() {
|
|||
case 'chat':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
// Don't touch isBrowserOpen here — chat navigation should land in
|
||||
// the right sidebar when the browser overlay is active.
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
|
|
@ -3101,7 +3184,7 @@ function App() {
|
|||
}, [])
|
||||
|
||||
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask
|
||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
|
|
@ -3964,7 +4047,7 @@ function App() {
|
|||
const selectedTask = selectedBackgroundTask
|
||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||
: null
|
||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen)
|
||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isBrowserOpen)
|
||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||
const openMarkdownTabs = React.useMemo(() => {
|
||||
|
|
@ -4006,7 +4089,7 @@ function App() {
|
|||
onNewChat: handleNewChatTab,
|
||||
onSelectRun: (runIdToLoad) => {
|
||||
cancelRecordingIfActive()
|
||||
if (selectedPath || isGraphOpen) {
|
||||
if (selectedPath || isGraphOpen || isBrowserOpen) {
|
||||
setIsChatSidebarOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -4016,8 +4099,8 @@ function App() {
|
|||
switchChatTab(existingTab.id)
|
||||
return
|
||||
}
|
||||
// In two-pane mode, keep current knowledge/graph context and just swap chat context.
|
||||
if (selectedPath || isGraphOpen) {
|
||||
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
|
||||
if (selectedPath || isGraphOpen || isBrowserOpen) {
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
||||
loadRun(runIdToLoad)
|
||||
return
|
||||
|
|
@ -4041,14 +4124,14 @@ function App() {
|
|||
} else {
|
||||
// Only one tab, reset it to new chat
|
||||
setChatTabs([{ id: tabForRun.id, runId: null }])
|
||||
if (selectedPath || isGraphOpen) {
|
||||
if (selectedPath || isGraphOpen || isBrowserOpen) {
|
||||
handleNewChat()
|
||||
} else {
|
||||
void navigateToView({ type: 'chat', runId: null })
|
||||
}
|
||||
}
|
||||
} else if (runId === runIdToDelete) {
|
||||
if (selectedPath || isGraphOpen) {
|
||||
if (selectedPath || isGraphOpen || isBrowserOpen) {
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
||||
handleNewChat()
|
||||
} else {
|
||||
|
|
@ -4146,7 +4229,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !selectedTask && (
|
||||
{!selectedPath && !isGraphOpen && !selectedTask && !isBrowserOpen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4161,7 +4244,7 @@ function App() {
|
|||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && expandedFrom && (
|
||||
{!selectedPath && !isGraphOpen && !isBrowserOpen && expandedFrom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4195,7 +4278,9 @@ function App() {
|
|||
)}
|
||||
</ContentHeader>
|
||||
|
||||
{selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
{isBrowserOpen ? (
|
||||
<BrowserPane onClose={handleCloseBrowser} />
|
||||
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BasesView
|
||||
tree={tree}
|
||||
|
|
@ -4562,12 +4647,18 @@ function App() {
|
|||
)}
|
||||
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
|
||||
<FixedSidebarToggle
|
||||
onNavigateBack={() => { void navigateBack() }}
|
||||
onNavigateForward={() => { void navigateForward() }}
|
||||
canNavigateBack={canNavigateBack}
|
||||
canNavigateForward={canNavigateForward}
|
||||
onNewChat={handleNewChatTab}
|
||||
onOpenSearch={() => setIsSearchOpen(true)}
|
||||
meetingState={meetingTranscription.state}
|
||||
meetingSummarizing={meetingSummarizing}
|
||||
meetingAvailable={voiceAvailable}
|
||||
onToggleMeeting={() => { void handleToggleMeeting() }}
|
||||
isBrowserOpen={isBrowserOpen}
|
||||
onToggleBrowser={handleToggleBrowser}
|
||||
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue