feat: file tabs

This commit is contained in:
Brooklyn Nicholson
2026-05-05 13:17:40 -05:00
parent 5ec0667fb3
commit 5269012c51
27 changed files with 763 additions and 133 deletions
+7 -1
View File
@@ -38,7 +38,13 @@ import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useVoiceConversation } from './hooks/use-voice-conversation'
import { useVoiceRecorder } from './hooks/use-voice-recorder'
import { composerPlainText, placeCaretEnd, refChipElement, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
import {
composerPlainText,
placeCaretEnd,
refChipElement,
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
import { SkinSlashPopover } from './skin-slash-popover'
import { ComposerTriggerPopover } from './trigger-popover'
import type { ChatBarProps } from './types'
@@ -36,6 +36,7 @@ type PreviewWebview = HTMLElement & {
}
interface PreviewPaneProps {
embedded?: boolean
onClose: () => void
onRestartServer?: (url: string, context?: string) => Promise<string>
reloadRequest?: number
@@ -359,15 +360,30 @@ function PreviewConsolePanel({
const selectedLogIds = useStore(consoleState.$selectedLogIds)
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
const stickScrollRafRef = useRef<number | null>(null)
useEffect(() => {
if (!consoleShouldStickRef.current) {
return
}
const consoleBody = consoleBodyRef.current
if (stickScrollRafRef.current !== null) {
window.cancelAnimationFrame(stickScrollRafRef.current)
stickScrollRafRef.current = null
}
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
stickScrollRafRef.current = window.requestAnimationFrame(() => {
stickScrollRafRef.current = null
const consoleBody = consoleBodyRef.current
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
})
return () => {
if (stickScrollRafRef.current !== null) {
window.cancelAnimationFrame(stickScrollRafRef.current)
stickScrollRafRef.current = null
}
}
}, [consoleBodyRef, consoleHeight, consoleShouldStickRef, logs])
function sendLogsToComposer(entries: ConsoleEntry[]) {
@@ -917,6 +933,7 @@ function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: Pr
const TITLEBAR_GROUP_ID = 'preview'
export function PreviewPane({
embedded = false,
onClose,
onRestartServer,
reloadRequest = 0,
@@ -1136,9 +1153,12 @@ export function PreviewPane({
consoleShouldStickRef.current = true
const consoleBody = consoleBodyRef.current
const handle = window.requestAnimationFrame(() => {
const consoleBody = consoleBodyRef.current
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
})
consoleBody?.scrollTo({ top: consoleBody.scrollHeight })
return () => window.cancelAnimationFrame(handle)
}, [consoleOpen])
useEffect(() => {
@@ -1423,21 +1443,23 @@ export function PreviewPane({
}, [appendConsoleEntry, consoleState, isWebPreview, target.url])
return (
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-background text-muted-foreground">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
<div className="min-w-0 flex-1">
<a
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
href={currentUrl}
rel="noreferrer"
target="_blank"
title={`Open ${currentUrl}`}
>
{previewLabel || 'Preview'}
</a>
{!embedded && (
<div className="pointer-events-none flex min-h-(--titlebar-height) items-center gap-1.5 border-b border-border/60 bg-background px-2 py-1">
<div className="min-w-0 flex-1">
<a
className="pointer-events-auto inline max-w-full cursor-pointer truncate text-left text-xs font-medium text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
href={currentUrl}
rel="noreferrer"
target="_blank"
title={`Open ${currentUrl}`}
>
{previewLabel || 'Preview'}
</a>
</div>
</div>
</div>
)}
<div
className="pointer-events-auto relative min-h-0 flex-1 overflow-hidden bg-background"
+153 -13
View File
@@ -1,12 +1,23 @@
import { useStore } from '@nanostores/react'
import { type MouseEvent, useCallback, useEffect, useMemo, useRef } from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$filePreviewTarget,
$rightRailActiveTabId,
RIGHT_RAIL_PREVIEW_TAB_ID,
type RightRailTabId,
selectRightRailTab
} from '@/store/layout'
import {
$filePreviewTabs,
$previewReloadRequest,
$previewTarget,
dismissFilePreviewTarget,
dismissPreviewTarget
closeFilePreviewTab,
dismissPreviewTarget,
type FilePreviewTab,
type PreviewTarget
} from '@/store/preview'
import { PreviewPane } from './preview-pane'
@@ -27,23 +38,152 @@ interface ChatPreviewRailProps {
setTitlebarToolGroup?: SetTitlebarToolGroup
}
interface RailTab {
closeLabel: string
id: RightRailTabId
label: string
target: PreviewTarget
}
function previewTabLabel(target: PreviewTarget): string {
const value = target.label || target.path || target.source || target.url
const parts = value.split(/[\\/]/).filter(Boolean)
return parts.at(-1) || value || 'Preview'
}
function tabLabel(tab: FilePreviewTab): string {
return previewTabLabel(tab.target)
}
export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) {
const previewReloadRequest = useStore($previewReloadRequest)
const filePreviewTarget = useStore($filePreviewTarget)
const activeTabId = useStore($rightRailActiveTabId)
const filePreviewTabs = useStore($filePreviewTabs)
const previewTarget = useStore($previewTarget)
const target = filePreviewTarget ?? previewTarget
if (!target) {
const tabs = useMemo<readonly RailTab[]>(
() => [
...(previewTarget
? [
{
closeLabel: 'Close preview',
id: RIGHT_RAIL_PREVIEW_TAB_ID,
label: 'Preview',
target: previewTarget
} satisfies RailTab
]
: []),
...filePreviewTabs.map(tab => ({
closeLabel: `Close ${tabLabel(tab)}`,
id: tab.id,
label: tabLabel(tab),
target: tab.target
}))
],
[filePreviewTabs, previewTarget]
)
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
// Read-by-ref so close handlers stay reference-stable across renders.
const activeTabRef = useRef<RailTab | undefined>(activeTab)
activeTabRef.current = activeTab
useEffect(() => {
if (activeTab && activeTab.id !== activeTabId) {
selectRightRailTab(activeTab.id)
}
}, [activeTab, activeTabId])
const closeRailTab = useCallback((tab: RailTab) => {
if (tab.id === RIGHT_RAIL_PREVIEW_TAB_ID) {
dismissPreviewTarget()
return
}
closeFilePreviewTab(tab.id)
}, [])
// Stable: PreviewPane lists onClose in a useEffect dep array that pushes
// titlebar tools. A fresh closure every render → setTitlebarToolGroup every
// render → DesktopController setState → re-render → ∞.
const handleCloseDocument = useCallback(() => {
const tab = activeTabRef.current
if (tab) {
closeRailTab(tab)
}
}, [closeRailTab])
const closeTab = (event: MouseEvent, tab: RailTab) => {
event.stopPropagation()
closeRailTab(tab)
}
if (!activeTab) {
return null
}
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
return (
<PreviewPane
onClose={filePreviewTarget ? dismissFilePreviewTarget : dismissPreviewTarget}
onRestartServer={filePreviewTarget ? undefined : onRestartServer}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
target={target}
/>
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
<div
className="flex h-(--titlebar-height) shrink-0 overflow-x-auto overflow-y-hidden overscroll-x-contain border-b border-border/60 bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_94%,transparent)] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{tabs.map(tab => {
const active = tab.id === activeTab.id
return (
<div
className={cn(
'group/tab relative flex h-full max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag]',
active
? 'bg-background text-foreground'
: 'border-r border-border/40 text-muted-foreground hover:bg-accent/30 hover:text-foreground'
)}
key={tab.id}
>
{active && <span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-primary/70" />}
<button
aria-selected={active}
className="flex h-full min-w-0 flex-1 items-center truncate pl-3 pr-1.5 text-left outline-none"
onClick={() => selectRightRailTab(tab.id)}
role="tab"
title={tab.label}
type="button"
>
{tab.label}
</button>
<button
aria-label={tab.closeLabel}
className={cn(
'mr-1.5 hidden size-4 shrink-0 place-items-center rounded-sm text-muted-foreground/55 transition-colors hover:bg-accent hover:text-foreground focus-visible:grid group-hover/tab:grid',
active && 'grid'
)}
onClick={event => closeTab(event, tab)}
title={tab.closeLabel}
type="button"
>
<X className="size-3" />
</button>
</div>
)
})}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<PreviewPane
embedded
onClose={handleCloseDocument}
onRestartServer={isPreview ? onRestartServer : undefined}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
target={activeTab.target}
/>
</div>
</aside>
)
}