mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-28 19:05:31 +02:00
implement keyboard shortcuts for tab management and adjust layout styles in App and TabBar components
This commit is contained in:
parent
087e809d4c
commit
09288571aa
2 changed files with 99 additions and 47 deletions
|
|
@ -585,7 +585,7 @@ function ContentHeader({
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
"titlebar-drag-region flex h-10 shrink-0 items-center gap-2 border-b border-border px-3 bg-sidebar transition-[padding] duration-200 ease-linear",
|
"titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border px-3 bg-sidebar transition-[padding] duration-200 ease-linear overflow-hidden",
|
||||||
// When the sidebar is collapsed the content area shifts left, so we need enough left padding
|
// When the sidebar is collapsed the content area shifts left, so we need enough left padding
|
||||||
// to avoid overlapping the fixed traffic-lights/toggle/back/forward controls.
|
// to avoid overlapping the fixed traffic-lights/toggle/back/forward controls.
|
||||||
isCollapsed && !collapsedLeftPaddingPx && "pl-[168px]"
|
isCollapsed && !collapsedLeftPaddingPx && "pl-[168px]"
|
||||||
|
|
@ -593,7 +593,7 @@ function ContentHeader({
|
||||||
style={isCollapsed && collapsedLeftPaddingPx ? { paddingLeft: collapsedLeftPaddingPx } : undefined}
|
style={isCollapsed && collapsedLeftPaddingPx ? { paddingLeft: collapsedLeftPaddingPx } : undefined}
|
||||||
>
|
>
|
||||||
{!isCollapsed && onNavigateBack && onNavigateForward ? (
|
{!isCollapsed && onNavigateBack && onNavigateForward ? (
|
||||||
<div className="titlebar-no-drag flex items-center gap-1">
|
<div className="titlebar-no-drag flex items-center gap-1 pr-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNavigateBack}
|
onClick={onNavigateBack}
|
||||||
|
|
@ -2040,6 +2040,61 @@ function App() {
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Keyboard shortcuts for tab management
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const mod = e.metaKey || e.ctrlKey
|
||||||
|
if (!mod) return
|
||||||
|
|
||||||
|
// Cmd+W — close active tab
|
||||||
|
if (e.key === 'w') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (activeSection === 'knowledge' && activeFileTabId) {
|
||||||
|
closeFileTab(activeFileTabId)
|
||||||
|
} else if (activeSection === 'tasks') {
|
||||||
|
closeChatTab(activeChatTabId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd+1..9 — switch to tab N (Cmd+9 always goes to last tab)
|
||||||
|
if (/^[1-9]$/.test(e.key)) {
|
||||||
|
e.preventDefault()
|
||||||
|
const n = parseInt(e.key, 10)
|
||||||
|
if (activeSection === 'knowledge') {
|
||||||
|
const idx = e.key === '9' ? fileTabs.length - 1 : n - 1
|
||||||
|
const tab = fileTabs[idx]
|
||||||
|
if (tab) switchFileTab(tab.id)
|
||||||
|
} else if (activeSection === 'tasks') {
|
||||||
|
const idx = e.key === '9' ? chatTabs.length - 1 : n - 1
|
||||||
|
const tab = chatTabs[idx]
|
||||||
|
if (tab) switchChatTab(tab.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd+Shift+] — next tab, Cmd+Shift+[ — previous tab
|
||||||
|
if (e.shiftKey && (e.key === ']' || e.key === '[')) {
|
||||||
|
e.preventDefault()
|
||||||
|
const direction = e.key === ']' ? 1 : -1
|
||||||
|
if (activeSection === 'knowledge') {
|
||||||
|
const currentIdx = fileTabs.findIndex(t => t.id === activeFileTabId)
|
||||||
|
if (currentIdx === -1) return
|
||||||
|
const nextIdx = (currentIdx + direction + fileTabs.length) % fileTabs.length
|
||||||
|
switchFileTab(fileTabs[nextIdx].id)
|
||||||
|
} else if (activeSection === 'tasks') {
|
||||||
|
const currentIdx = chatTabs.findIndex(t => t.id === activeChatTabId)
|
||||||
|
if (currentIdx === -1) return
|
||||||
|
const nextIdx = (currentIdx + direction + chatTabs.length) % chatTabs.length
|
||||||
|
switchChatTab(chatTabs[nextIdx].id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleTabKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleTabKeyDown)
|
||||||
|
}, [activeSection, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
|
||||||
|
|
||||||
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||||
if (kind === 'file') {
|
if (kind === 'file') {
|
||||||
navigateToFile(path)
|
navigateToFile(path)
|
||||||
|
|
@ -2499,13 +2554,6 @@ function App() {
|
||||||
const conversationContentClassName = hasConversation
|
const conversationContentClassName = hasConversation
|
||||||
? "mx-auto w-full max-w-4xl pb-28"
|
? "mx-auto w-full max-w-4xl pb-28"
|
||||||
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
|
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
|
||||||
const headerTitle = selectedPath
|
|
||||||
? getBaseName(selectedPath)
|
|
||||||
: isGraphOpen
|
|
||||||
? 'Graph View'
|
|
||||||
: selectedBackgroundTask
|
|
||||||
? `Background Task: ${selectedBackgroundTask}`
|
|
||||||
: 'Chat'
|
|
||||||
const selectedTask = selectedBackgroundTask
|
const selectedTask = selectedBackgroundTask
|
||||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||||
: null
|
: null
|
||||||
|
|
@ -2585,7 +2633,7 @@ function App() {
|
||||||
canNavigateForward={canNavigateForward}
|
canNavigateForward={canNavigateForward}
|
||||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||||
>
|
>
|
||||||
{activeSection === 'knowledge' && fileTabs.length > 1 ? (
|
{activeSection === 'knowledge' && fileTabs.length >= 1 ? (
|
||||||
<TabBar
|
<TabBar
|
||||||
tabs={fileTabs}
|
tabs={fileTabs}
|
||||||
activeTabId={activeFileTabId ?? ''}
|
activeTabId={activeFileTabId ?? ''}
|
||||||
|
|
@ -2594,7 +2642,7 @@ function App() {
|
||||||
onSwitchTab={switchFileTab}
|
onSwitchTab={switchFileTab}
|
||||||
onCloseTab={closeFileTab}
|
onCloseTab={closeFileTab}
|
||||||
/>
|
/>
|
||||||
) : activeSection === 'tasks' && chatTabs.length > 1 ? (
|
) : (
|
||||||
<TabBar
|
<TabBar
|
||||||
tabs={chatTabs}
|
tabs={chatTabs}
|
||||||
activeTabId={activeChatTabId}
|
activeTabId={activeChatTabId}
|
||||||
|
|
@ -2604,10 +2652,6 @@ function App() {
|
||||||
onSwitchTab={switchChatTab}
|
onSwitchTab={switchChatTab}
|
||||||
onCloseTab={closeChatTab}
|
onCloseTab={closeChatTab}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<span className="text-sm font-medium text-muted-foreground flex-1 min-w-0 truncate">
|
|
||||||
{headerTitle}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -2618,7 +2662,7 @@ function App() {
|
||||||
<SearchIcon className="size-4" />
|
<SearchIcon className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
{selectedPath && (
|
{selectedPath && (
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground self-center shrink-0 pl-2">
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
<LoaderIcon className="h-3 w-3 animate-spin" />
|
<LoaderIcon className="h-3 w-3 animate-spin" />
|
||||||
|
|
@ -2637,7 +2681,7 @@ function App() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { void navigateToView({ type: 'chat', runId }) }}
|
onClick={() => { void navigateToView({ type: 'chat', runId }) }}
|
||||||
className="titlebar-no-drag text-foreground"
|
className="titlebar-no-drag text-foreground self-center shrink-0"
|
||||||
>
|
>
|
||||||
Close Graph
|
Close Graph
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -2646,7 +2690,7 @@ function App() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCloseFullScreenChat}
|
onClick={handleCloseFullScreenChat}
|
||||||
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0"
|
||||||
aria-label="Return to file"
|
aria-label="Return to file"
|
||||||
>
|
>
|
||||||
<X className="size-5" />
|
<X className="size-5" />
|
||||||
|
|
@ -2656,7 +2700,7 @@ function App() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsChatSidebarOpen(!isChatSidebarOpen)}
|
onClick={() => setIsChatSidebarOpen(!isChatSidebarOpen)}
|
||||||
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors -mr-1"
|
className="titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors -mr-1 self-center shrink-0"
|
||||||
aria-label="Toggle Chat Sidebar"
|
aria-label="Toggle Chat Sidebar"
|
||||||
>
|
>
|
||||||
<PanelRightIcon className="size-5" />
|
<PanelRightIcon className="size-5" />
|
||||||
|
|
|
||||||
|
|
@ -32,44 +32,52 @@ export function TabBar<T>({
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
}: TabBarProps<T>) {
|
}: 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 self-stretch min-w-0 overflow-hidden">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab, index) => {
|
||||||
const tabId = getTabId(tab)
|
const tabId = getTabId(tab)
|
||||||
const isActive = tabId === activeTabId
|
const isActive = tabId === activeTabId
|
||||||
const processing = isProcessing?.(tab) ?? false
|
const processing = isProcessing?.(tab) ?? false
|
||||||
const title = getTabTitle(tab)
|
const title = getTabTitle(tab)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<React.Fragment key={tabId}>
|
||||||
key={tabId}
|
{index > 0 && (
|
||||||
type="button"
|
<div className="self-stretch w-px bg-border/70 shrink-0" aria-hidden="true" />
|
||||||
onClick={() => onSwitchTab(tabId)}
|
|
||||||
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",
|
|
||||||
isActive
|
|
||||||
? "bg-accent text-accent-foreground"
|
|
||||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
|
||||||
)}
|
)}
|
||||||
>
|
<button
|
||||||
{processing && (
|
type="button"
|
||||||
<span className="size-1.5 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
onClick={() => onSwitchTab(tabId)}
|
||||||
)}
|
|
||||||
<span className="truncate flex-1 text-left">{title}</span>
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 flex items-center justify-center rounded-sm p-0.5 hover:bg-foreground/10 transition-colors",
|
"group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs min-w-0 max-w-[220px] transition-colors",
|
||||||
isActive ? "opacity-60 hover:opacity-100" : "opacity-0 group-hover/tab:opacity-60 hover:!opacity-100"
|
isActive
|
||||||
|
? "bg-background text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
style={{ flex: '1 1 0px' }}
|
||||||
e.stopPropagation()
|
|
||||||
onCloseTab(tabId)
|
|
||||||
}}
|
|
||||||
aria-label="Close tab"
|
|
||||||
>
|
>
|
||||||
<X className="size-3" />
|
{processing && (
|
||||||
</span>
|
<span className="size-1.5 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
</button>
|
)}
|
||||||
|
<span className="truncate flex-1 text-left">{title}</span>
|
||||||
|
{tabs.length > 1 && (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
className="shrink-0 flex items-center justify-center rounded-sm p-0.5 opacity-0 group-hover/tab:opacity-60 hover:opacity-100! hover:bg-foreground/10 transition-all"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onCloseTab(tabId)
|
||||||
|
}}
|
||||||
|
aria-label="Close tab"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* Right edge divider after last tab to close off the section */}
|
||||||
|
{index === tabs.length - 1 && (
|
||||||
|
<div className="self-stretch w-px bg-border/70 shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue