diff --git a/apps/desktop/src/app/chat/close-tab.test.ts b/apps/desktop/src/app/chat/close-tab.test.ts index 65642734bc..880e2edbbe 100644 --- a/apps/desktop/src/app/chat/close-tab.test.ts +++ b/apps/desktop/src/app/chat/close-tab.test.ts @@ -20,7 +20,6 @@ vi.mock('@/store/profile', () => ({ requestFreshSession: () => requestFreshSession() })) -import { $rightRailActiveTabId } from '@/store/layout' import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview' import { $activeSessionId, $selectedStoredSessionId } from '@/store/session' @@ -65,25 +64,18 @@ afterEach(() => { }) describe('closeActiveTab', () => { - it('closes the active file preview tab (⌘W happy path)', () => { + // Preview tabs are layout-tree panes now, so ⌘W reaches them through the + // focused-zone rungs (closeFocusedSessionTab / closeFocusedToolTab → the + // pane's registered closer) rather than a rail-shaped special case. Open + // previews must therefore NOT claim the key on their own. + it('leaves ⌘W to the zone rungs even with previews open', () => { openPreview(fileTarget('/work/notes.md'), 'manual') - - expect($previewTabs.get()).toHaveLength(1) - expect($rightRailActiveTabId.get()).toBe('file:file:///work/notes.md') - - expect(closeActiveTab()).toBe(true) - expect($previewTabs.get()).toHaveLength(0) - }) - - it('closes the visible tab when the active selection points at a tab that is gone', () => { - // The rail falls back to tabs[0] until React syncs the selection, so ⌘W has - // to act on what is actually on screen rather than no-op'ing. - openPreview(fileTarget('/work/notes.md'), 'manual') - $rightRailActiveTabId.set('file:file:///work/stale.md') + closeFocusedToolTab.mockReturnValue(true) expect($previewTabs.get()).toHaveLength(1) expect(closeActiveTab()).toBe(true) - expect($previewTabs.get()).toHaveLength(0) + // The zone closed its own tab; the rail store was never consulted. + expect($previewTabs.get()).toHaveLength(1) }) }) @@ -132,7 +124,7 @@ describe('closeWorkspaceTab', () => { expect(requestFreshSession).not.toHaveBeenCalled() }) - it('⌘W reaches it once the terminal, rail and zone tabs pass', () => { + it('⌘W reaches it once the terminal and zone tabs pass', () => { loadedMainOnly() expect(closeActiveTab(vi.fn())).toBe(true) diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index 29284c919d..4304138904 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -3,7 +3,6 @@ import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals' import { $workspaceIsPage } from '@/app/routes' import { closeFocusedSessionTab, closeFocusedToolTab } from '@/components/pane-shell/tree/store' import { isFocusWithin } from '@/lib/keybinds/combo' -import { $previewTabs, closeActiveRightRailTab } from '@/store/preview' import { requestFreshSession } from '@/store/profile' import { $activeSessionId, $selectedStoredSessionId } from '@/store/session' import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states' @@ -54,15 +53,17 @@ export function closeWorkspaceTab(loadSessionIntoWorkspace?: (storedSessionId: s /** * ⌘W — close the tab of the context you're in, by precedence: * 1. a focused terminal → its active terminal tab, - * 2. right-rail tabs (live preview and/or file peeks), - * 3. the FOCUSED chat zone → its active tab (a session tile stacked into it). - * 4. a focused TOOL PANEL zone (terminal / logs) → its active tab. - * 5. the workspace tab itself — see `closeWorkspaceTab`. + * 2. the FOCUSED chat zone → its active tab (a session tile stacked into it). + * 3. a focused TOOL PANEL zone (terminal / logs) → its active tab. + * 4. the workspace tab itself — see `closeWorkspaceTab`. * Returns false when nothing closes, so ⌘W is a no-op — it never closes the * window. Shared by the keyboard path (Win/Linux) and the macOS * menu-accelerator IPC. * - * Steps 3-5 follow the same focused zone ⌘1…⌘9 indexes, so a second chat zone + * Preview tabs need no rung of their own: they are layout-tree panes like any + * other tab, so the focused-zone rungs close them through `closeTabPane`. + * + * Steps 2-4 follow the same focused zone ⌘1…⌘9 indexes, so a second chat zone * with its own tab strip closes ITS tab instead of main's. */ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean { @@ -72,13 +73,6 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri return true } - // Gate on tab *presence*, not on the selection: a stale `$rightRailActiveTabId` - // would otherwise make ⌘W fall through to closeFocusedSessionTab() and look - // broken with a tab still on screen. The store resolves which tab that is. - if ($previewTabs.get().length > 0) { - return closeActiveRightRailTab() - } - // A closeable tab in the focused chat zone (a session tile that's the active // tab) closes outright; the uncloseable workspace tab falls through. if (closeFocusedSessionTab()) { diff --git a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx index 995b1b8cc8..8fd06b6b3d 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx @@ -9,9 +9,7 @@ import { useI18n } from '@/i18n' import { isDesktopFsRemoteMode } from '@/lib/desktop-fs' import { normalizeOrLocalPreviewTarget, openPreviewTargetInBrowser } from '@/lib/local-preview' import { cn } from '@/lib/utils' -import { PREVIEW_PANE_ID } from '@/store/layout' import { notifyError } from '@/store/notifications' -import { $paneOpen } from '@/store/panes' import { $previewTabSources, closePreviewForSource, openPreview } from '@/store/preview' import { type PreviewArtifact } from '@/store/preview-status' @@ -24,9 +22,9 @@ interface PreviewStatusRowProps { export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss }: PreviewStatusRowProps) { const { t } = useI18n() const openSources = useStore($previewTabSources) - const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID)) const [opening, setOpening] = useState(false) - const isOpen = openSources.includes(item.target) && previewPaneOpen + // A tab open IS a pane in the tree now, so its presence is the whole answer. + const isOpen = openSources.includes(item.target) const resolveTarget = async () => { const target = await normalizeOrLocalPreviewTarget(item.target, item.cwd || undefined) diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts index 03a6e16789..03c7e5bc3c 100644 --- a/apps/desktop/src/app/chat/pane-mirror.ts +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -11,6 +11,7 @@ import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from import type { DoubleTapContext } from '@/components/pane-shell/tree/renderer/drag-session' import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store' +import type { PaneStripTool } from '@/components/ui/pane-tab' import { registry } from '@/contrib/registry' import type { TileDock } from '@/store/session-states' @@ -35,6 +36,11 @@ export interface PaneMirror { * self-subscribing component (e.g. a session's status dot) so the strip needn't * re-sync on status/color change — only `title` drives re-registration. */ tabLead?: (key: string) => ReactNode + /** Glyph buttons the tile contributes to the strip, after the last tab (where + * "+" sits), while it is the ACTIVE pane — e.g. a preview's console / + * DevTools toggles. DATA, not markup: the strip's `PaneStripGlyph` owns the + * styling so every glyph on every strip matches. */ + stripTools?: (key: string) => readonly PaneStripTool[] render: (key: string) => ReactNode /** Wrap the tile's TAB (domain context menu — session verbs). */ tabWrap?: (key: string, tab: ReactElement) => ReactNode @@ -76,12 +82,16 @@ export function paneMirror(cfg: PaneMirror): () => void { title, data: { tabLead: cfg.tabLead ? () => cfg.tabLead!(key) : undefined, + stripTools: cfg.stripTools ? () => cfg.stripTools!(key) : undefined, dock: { before: cfg.before?.(tile), pane: cfg.anchor?.(tile) ?? 'workspace', pos: cfg.dir?.(tile) ?? 'right' }, minWidth: cfg.minWidth, + // Every mirrored tile is a full workspace surface docked beside main — + // and closeable, which is what keeps its tab when it lands in a zone of + // its own (see lone-header.ts). placement: 'main', tabDrag: cfg.tabDrag ? (event: ReactPointerEvent, onTap: () => void, double?: DoubleTapContext) => diff --git a/apps/desktop/src/app/chat/preview-tile.tsx b/apps/desktop/src/app/chat/preview-tile.tsx new file mode 100644 index 0000000000..dc59a6f602 --- /dev/null +++ b/apps/desktop/src/app/chat/preview-tile.tsx @@ -0,0 +1,143 @@ +/** + * PREVIEW TILES — every open preview (a file, a URL, an artifact) rendered as a + * layout-tree pane, the preview analog of session and route tiles. + * + * The rail used to bring its OWN tab strip: a second bar beside the zone's own, + * at a different height, with its own close menu and its own label casing. It + * predated the layout tree. Now `$previewTabs` mirrors into pane contributions + * through the same `paneMirror` the other tiles use, so a preview tab IS a zone + * tab — same strip, same drag/stack/split, same ⌘W, same right-click verbs, and + * one bar instead of two. + */ + +import { findGroup } from '@/components/pane-shell/tree/model' +import { $activeTreeGroup, $layoutTree, revealTreePane } from '@/components/pane-shell/tree/store' +import { FileTypeIcon } from '@/components/ui/file-type-icon' +import { ToolIcon } from '@/components/ui/tool-icon' +import { $rightRailActiveTabId, type RightRailTabId, selectRightRailTab } from '@/store/layout' +import { $previewTabs, closeRightRailTab, type PreviewTarget } from '@/store/preview' + +import { paneMirror } from './pane-mirror' +import { PreviewTilePane } from './right-rail/preview' +import { forgetPreviewStripTools, previewStripTools } from './right-rail/preview-strip-tools' + +/** The target behind a tile id, or null once its tab is gone. */ +function targetFor(tabId: string): PreviewTarget | null { + return $previewTabs.get().find(tab => tab.id === tabId)?.target ?? null +} + +/** Tab title. A URL is a BROWSER — the tab names the surface, not the page, so + * it doesn't rename itself on every navigation. A file names the file; an + * artifact is titled rather than located, so its label is the whole name. */ +function previewTitle(tabId: string): string { + const target = targetFor(tabId) + + if (!target) { + return 'Preview' + } + + if (target.kind === 'url') { + return 'Browser' + } + + if (target.kind === 'artifact') { + return target.label || 'Preview' + } + + const value = target.label || target.path || target.source || target.url + const tail = value.split(/[\\/]/).filter(Boolean).at(-1) + + return tail || value || 'Preview' +} + +/** The tab's lead glyph — the same file/tool icon family the file tree and code + * fences resolve through, so a `.tsx` peek and its sidebar row agree. */ +function PreviewTabLead({ tabId }: { tabId: string }) { + const target = targetFor(tabId) + + if (!target) { + return null + } + + if (target.kind === 'artifact') { + return + } + + if (target.kind === 'url') { + return + } + + return +} + +const PREVIEW_TILE_PREFIX = 'preview-tile' + +/** Keep pane contributions mirroring `$previewTabs`, keep the store's selection + * and the tree's active pane agreeing, and front a tile when its tab is + * selected. Call once from the root. */ +export function watchPreviewTiles(): void { + watchPreviewTileMirror() + + // The reveal analog of session tiles (session-states calls revealTreePane on + // open): `openPreview` selects the tab, and the TREE must front its pane — + // un-minimize, un-hide, activate in its zone. Both stores, because re-opening + // the already-active tab changes only `$previewTabs` (fresh tab object), while + // switching tabs changes only the active id. + const reveal = () => { + const tabId = $rightRailActiveTabId.get() + + if (tabId && targetFor(tabId)) { + revealTreePane(`${PREVIEW_TILE_PREFIX}:${tabId}`) + } + } + + $rightRailActiveTabId.listen(reveal) + $previewTabs.listen(reveal) + + // And the reverse: clicking a preview TAB activates its pane in the TREE + // only, so the store's selection must follow or `$previewTarget` (⌘L quote + // labels, the titlebar's has-preview state) keeps reporting the previous + // tab. Same derivation `$focusedStoredSessionId` uses: the interacted zone's + // active pane names the tab. Converges with `reveal` — re-selecting the id + // the tree already fronts is a no-op in both directions. + const follow = () => { + const tree = $layoutTree.get() + const groupId = $activeTreeGroup.get() + const active = groupId && tree ? findGroup(tree, groupId)?.active : undefined + + if (!active?.startsWith(`${PREVIEW_TILE_PREFIX}:`)) { + return + } + + const tabId = active.slice(PREVIEW_TILE_PREFIX.length + 1) as RightRailTabId + + if (targetFor(tabId) && $rightRailActiveTabId.get() !== tabId) { + selectRightRailTab(tabId) + } + } + + $layoutTree.listen(follow) + $activeTreeGroup.listen(follow) +} + +const watchPreviewTileMirror = paneMirror<{ id: string }>({ + source: $previewTabs, + key: tab => tab.id, + prefix: PREVIEW_TILE_PREFIX, + // Identical to route (page) tiles: its own zone docked beside main, sized by + // the split weights. NOT anchored to the file tree — the old rail was a + // files-adjacent strip, and carrying that over welded preview into the file + // browser's zone, so ⌘J (toggle file browser) took the preview with it. + dir: () => 'right', + minWidth: '22rem', + title: previewTitle, + tabLead: tabId => , + // Console + DevTools as bare strip glyphs after the last tab, where "+" sits. + // Only a URL preview has a webview behind it, so a file/artifact tab gets none. + stripTools: tabId => (targetFor(tabId)?.kind === 'url' ? previewStripTools(tabId) : []), + render: tabId => , + close: tabId => { + forgetPreviewStripTools(tabId) + closeRightRailTab(tabId) + } +}) diff --git a/apps/desktop/src/app/chat/right-rail/index.ts b/apps/desktop/src/app/chat/right-rail/index.ts index c79955718d..089dc605b6 100644 --- a/apps/desktop/src/app/chat/right-rail/index.ts +++ b/apps/desktop/src/app/chat/right-rail/index.ts @@ -1 +1 @@ -export { ChatPreviewRail, PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from './preview' +export { PreviewTilePane } from './preview' diff --git a/apps/desktop/src/app/chat/right-rail/preview-console.tsx b/apps/desktop/src/app/chat/right-rail/preview-console.tsx index 35001ab002..d504b6e347 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-console.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-console.tsx @@ -132,18 +132,6 @@ function ConsoleRow({ copyText, log, onSend, onToggleSelect, selected }: Console ) } -export function PreviewConsoleTitlebarIcon({ consoleState }: { consoleState: PreviewConsoleState }) { - const { t } = useI18n() - const logCount = useStore(consoleState.$logCount) - - return ( - <> - - {logCount > 0 && {t.preview.console.messages(logCount)}} - - ) -} - interface PreviewConsolePanelProps { consoleBodyRef: RefObject consoleShouldStickRef: MutableRefObject diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx index b5236bdc96..3a98e67257 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $connection } from '@/store/session' import { PreviewPane } from './preview-pane' +import { forgetPreviewStripTools, previewConsoleState } from './preview-strip-tools' describe('PreviewPane console state', () => { beforeEach(() => { @@ -34,7 +35,6 @@ describe('PreviewPane console state', () => { await act(async () => { render( { expect(onPreviewFileChanged).not.toHaveBeenCalled() }) - it('does not rebuild the pane titlebar group for streamed console logs', async () => { - const setTitlebarToolGroup = vi.fn() + // The console lives in the TAB's store (the toggles sit on the tab, not in the + // titlebar), so a streamed log has to land in the store keyed by tabId — that + // is what both the panel in the pane and the button on the tab read. + it('streams console logs into the tab-keyed console store', async () => { + const tabId = 'url:http://localhost:5174' + + forgetPreviewStripTools(tabId) let rendered!: ReturnType await act(async () => { rendered = render( { ) }) - const initialCalls = setTitlebarToolGroup.mock.calls.length const webview = rendered.container.querySelector('webview') expect(webview).toBeInstanceOf(HTMLElement) @@ -84,12 +88,13 @@ describe('PreviewPane console state', () => { ) }) - expect(setTitlebarToolGroup).toHaveBeenCalledTimes(initialCalls) + expect(previewConsoleState(tabId).$logs.get().at(-1)?.message).toBe('streamed log line') + + forgetPreviewStripTools(tabId) }) it('renders authenticated remote HTML safely and honors source mode', async () => { const dataUrl = `data:text/html;base64,${btoa('

remote

')}` - const setTitlebarToolGroup = vi.fn() const target = { dataUrl, @@ -103,26 +108,21 @@ describe('PreviewPane console state', () => { let rendered!: ReturnType await act(async () => { - rendered = render() + rendered = render() }) const iframe = rendered.container.querySelector('iframe') - const tools = setTitlebarToolGroup.mock.calls.at(-1)?.[1] ?? [] expect(rendered.container.querySelector('webview')).toBeNull() expect(iframe?.getAttribute('sandbox')).toBe('') expect(iframe?.getAttribute('referrerpolicy')).toBe('no-referrer') expect(iframe?.getAttribute('srcdoc')).toContain(`default-src 'none'`) expect(iframe?.getAttribute('srcdoc')).toContain('

remote

') - expect(tools).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'preview-devtools' })])) expect(rendered.container.textContent).not.toContain(dataUrl) await act(async () => { rendered.rerender( - + ) }) diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index 1c287a5432..054600b5e2 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -2,11 +2,9 @@ import { useStore } from '@nanostores/react' import type { PointerEvent as ReactPointerEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' import { isDesktopFsRemoteMode } from '@/lib/desktop-fs' -import { Bug } from '@/lib/icons' import { openPreviewTargetInBrowser, remoteHtmlPreviewDocument } from '@/lib/local-preview' import { rafCoalesce } from '@/lib/raf-coalesce' import { cn } from '@/lib/utils' @@ -19,11 +17,11 @@ import { compactUrl, formatLogLine, isNearConsoleBottom, - PreviewConsolePanel, - PreviewConsoleTitlebarIcon + PreviewConsolePanel } from './preview-console' -import { type ConsoleEntry, createPreviewConsoleState } from './preview-console-state' +import { type ConsoleEntry } from './preview-console-state' import { LocalFilePreview, PreviewEmptyState } from './preview-file' +import { previewConsoleState, registerPreviewDevTools } from './preview-strip-tools' type PreviewWebview = HTMLElement & { closeDevTools?: () => void @@ -38,7 +36,9 @@ interface PreviewPaneProps { embedded?: boolean onRestartServer?: (url: string, context?: string) => Promise reloadRequest?: number - setTitlebarToolGroup?: SetTitlebarToolGroup + /** The preview tab this pane renders. Keys the per-tab console store and the + * DevTools handle the STRIP glyphs read (see preview-strip-tools). */ + tabId?: string target: PreviewTarget } @@ -121,18 +121,12 @@ function PreviewLoadError({ ) } -const TITLEBAR_GROUP_ID = 'preview' - -export function PreviewPane({ - embedded = false, - onRestartServer, - reloadRequest = 0, - setTitlebarToolGroup, - target -}: PreviewPaneProps) { +export function PreviewPane({ embedded = false, onRestartServer, reloadRequest = 0, tabId, target }: PreviewPaneProps) { const { t } = useI18n() const copy = t.preview.web - const [consoleState] = useState(() => createPreviewConsoleState()) + // The console store belongs to the TAB, not this render: the toggles live on + // the tab and must read the same logs this pane appends to. + const consoleState = previewConsoleState(tabId ?? target.url) const consoleBodyRef = useRef(null) const consoleShouldStickRef = useRef(true) const hostRef = useRef(null) @@ -297,45 +291,31 @@ export function PreviewPane({ if (webview.isDevToolsOpened?.()) { webview.closeDevTools?.() - setDevtoolsOpen(false) return } webview.openDevTools() - setDevtoolsOpen(true) }, []) + // Publish the DevTools handle for THIS tab so the tab's own toggle can drive + // the webview (which only exists in here). Registered on every open/close + // change so the button's active state stays truthful. useEffect(() => { - if (!setTitlebarToolGroup) { + if (!isWebPreview || !tabId) { return } - const tools: TitlebarTool[] = [ - ...(isWebPreview && !isRemoteHtml - ? [ - { - active: consoleOpen, - icon: , - id: `${TITLEBAR_GROUP_ID}-console`, - label: consoleOpen ? copy.hideConsole : copy.showConsole, - onSelect: () => consoleState.setOpen(open => !open) - }, - { - active: devtoolsOpen, - icon: , - id: `${TITLEBAR_GROUP_ID}-devtools`, - label: devtoolsOpen ? copy.hideDevTools : copy.openDevTools, - onSelect: toggleDevTools - } - ] - : []) - ] + // Remote HTML renders in a sandboxed iframe, not a webview — there is no + // console and no DevTools to offer (same guard the titlebar tools had). + if (isRemoteHtml) { + return + } - setTitlebarToolGroup(TITLEBAR_GROUP_ID, tools) + registerPreviewDevTools(tabId, { open: devtoolsOpen, toggle: toggleDevTools }) - return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, []) - }, [consoleOpen, consoleState, copy, devtoolsOpen, isRemoteHtml, isWebPreview, setTitlebarToolGroup, toggleDevTools]) + return () => registerPreviewDevTools(tabId, null) + }, [devtoolsOpen, isRemoteHtml, isWebPreview, tabId, toggleDevTools]) // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { @@ -609,8 +589,15 @@ export function PreviewPane({ const onStart = () => setLoading(true) const onStop = () => setLoading(false) + // The WEBVIEW is the source of truth for DevTools, not our click handler: + // closing the DevTools window itself fires devtools-closed with no click, + // and the glyph was left stuck "on" when we tracked it locally. + const onDevToolsOpened = () => setDevtoolsOpen(true) + const onDevToolsClosed = () => setDevtoolsOpen(false) webview.addEventListener('console-message', onConsole) + webview.addEventListener('devtools-closed', onDevToolsClosed) + webview.addEventListener('devtools-opened', onDevToolsOpened) webview.addEventListener('did-fail-load', onFail) webview.addEventListener('did-navigate', onNavigate) webview.addEventListener('did-navigate-in-page', onNavigate) @@ -621,6 +608,8 @@ export function PreviewPane({ return () => { webview.removeEventListener('console-message', onConsole) + webview.removeEventListener('devtools-closed', onDevToolsClosed) + webview.removeEventListener('devtools-opened', onDevToolsOpened) webview.removeEventListener('did-fail-load', onFail) webview.removeEventListener('did-navigate', onNavigate) webview.removeEventListener('did-navigate-in-page', onNavigate) diff --git a/apps/desktop/src/app/chat/right-rail/preview-strip-tools.tsx b/apps/desktop/src/app/chat/right-rail/preview-strip-tools.tsx new file mode 100644 index 0000000000..ddbd92ba4f --- /dev/null +++ b/apps/desktop/src/app/chat/right-rail/preview-strip-tools.tsx @@ -0,0 +1,97 @@ +/** + * Per-preview strip tools — the console toggle and the DevTools toggle. + * + * These were titlebar tools (`setTitlebarToolGroup`): one global pair, far from + * the thing they act on and ambiguous the moment two previews were open. They're + * strip glyphs now, contributed as `PaneStripTool` DATA — the strip renders them + * with `PaneStripGlyph`, the same button the "+" is, so there is no preview-owned + * styling to drift. + * + * The state has to outlive any one pane render and be addressable by tab id: the + * console store is created lazily per tab and cached here, and `PreviewPane` + * registers its DevTools handle for the tab it renders. Both invalidate the + * strip so `active` / `disabled` stay truthful. + */ + +import { invalidateStripTools } from '@/components/pane-shell/tree/store' +import { Codicon } from '@/components/ui/codicon' +import type { PaneStripTool } from '@/components/ui/pane-tab' +import { translateNow } from '@/i18n' + +import { createPreviewConsoleState, type PreviewConsoleState } from './preview-console-state' + +interface DevToolsHandle { + open: boolean + toggle: () => void +} + +const consoleStates = new Map() + +/** The console store for a tab, created on first use. Cached so the strip glyph + * and the panel in the pane read the SAME store. */ +export function previewConsoleState(tabId: string): PreviewConsoleState { + const existing = consoleStates.get(tabId) + + if (existing) { + return existing + } + + const created = createPreviewConsoleState() + + // Opening/closing the console flips the glyph's `active`, and the strip reads + // its tools during render — so tell it to re-read. + created.$open.listen(() => invalidateStripTools()) + consoleStates.set(tabId, created) + + return created +} + +/** DevTools handles by tab id. The pane registers on mount (the webview only + * exists in there); until it lands the glyph renders disabled. */ +const devToolsHandles = new Map() + +export function registerPreviewDevTools(tabId: string, handle: DevToolsHandle | null) { + if (handle) { + devToolsHandles.set(tabId, handle) + } else if (!devToolsHandles.delete(tabId)) { + return + } + + invalidateStripTools() +} + +/** Drop a closed tab's state so a long-lived window doesn't pin every preview it + * ever opened. */ +export function forgetPreviewStripTools(tabId: string) { + consoleStates.delete(tabId) + registerPreviewDevTools(tabId, null) +} + +/** + * The console + DevTools glyphs for a preview, as strip-tool DATA. Only wired for + * `url` previews — a file/artifact peek has no webview, hence no console and no + * DevTools. + */ +export function previewStripTools(tabId: string): readonly PaneStripTool[] { + const consoleState = previewConsoleState(tabId) + const consoleOpen = consoleState.$open.get() + const devTools = devToolsHandles.get(tabId) + + return [ + { + active: consoleOpen, + icon: , + id: 'preview-console', + label: translateNow(consoleOpen ? 'preview.web.hideConsole' : 'preview.web.showConsole'), + onSelect: () => consoleState.setOpen(open => !open) + }, + { + active: devTools?.open, + disabled: !devTools, + icon: , + id: 'preview-devtools', + label: translateNow(devTools?.open ? 'preview.web.hideDevTools' : 'preview.web.openDevTools'), + onSelect: () => devTools?.toggle() + } + ] +} diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index 7e0e3ec46a..48ebdce6ac 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -1,168 +1,43 @@ import { useStore } from '@nanostores/react' -import { useEffect, useMemo } from 'react' -import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls' -import { Codicon } from '@/components/ui/codicon' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' -import { PaneTab, PaneTabLabel } from '@/components/ui/pane-tab' -import { Tip } from '@/components/ui/tooltip' -import { translateNow, useI18n } from '@/i18n' -import { formatCombo } from '@/lib/keybinds/combo' -import { cn } from '@/lib/utils' -import { $panesFlipped, $rightRailActiveTabId, selectRightRailTab } from '@/store/layout' -import { - $previewReloadRequest, - $previewTabs, - closeOtherRightRailTabs, - closeRightRail, - closeRightRailTab, - closeRightRailTabsToRight, - type PreviewTarget -} from '@/store/preview' -import { $dirtyPreviewUrls } from '@/store/preview-edit' +import { $restartPreviewServer } from '@/app/contrib/panes' +import { $previewReloadRequest, $previewTabs } from '@/store/preview' import { PreviewPane } from './preview-pane' -export const PREVIEW_RAIL_MIN_WIDTH = '18rem' -export const PREVIEW_RAIL_MAX_WIDTH = '38rem' - -interface ChatPreviewRailProps { - onRestartServer?: (url: string, context?: string) => Promise - setTitlebarToolGroup?: SetTitlebarToolGroup +interface PreviewTilePaneProps { + /** The `$previewTabs` id this pane renders. */ + tabId: string } -function tabLabelFor(target: PreviewTarget): string { - // Artifacts are titled, not located — their label is the whole name. - if (target.kind === 'artifact') { - return target.label || translateNow('preview.tab') - } - - const value = target.label || target.path || target.source || target.url - const tail = value.split(/[\\/]/).filter(Boolean).at(-1) - - return tail || value || translateNow('preview.tab') -} - -export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatPreviewRailProps) { - const { t } = useI18n() +/** + * One preview, as a layout-tree pane. The tab strip — its label, close verbs, + * drag/stack/split and ⌘W — belongs to the ZONE (see `preview-tile.tsx`), so + * this renders only the body and a preview tab behaves like every other tab. + * + * The console / DevTools glyphs live in the zone STRIP (`preview-strip-tools`), + * keyed by `tabId`; the restart handler arrives through the atom bridge the old + * rail wrapper used, since the mirror renders this pane with no props to thread. + */ +export function PreviewTilePane({ tabId }: PreviewTilePaneProps) { const previewReloadRequest = useStore($previewReloadRequest) - const activeTabId = useStore($rightRailActiveTabId) - const panesFlipped = useStore($panesFlipped) const previewTabs = useStore($previewTabs) - const dirtyPreviewUrls = useStore($dirtyPreviewUrls) + const restartPreviewServer = useStore($restartPreviewServer) + const target = previewTabs.find(tab => tab.id === tabId)?.target - const tabs = useMemo( - () => - previewTabs.map(({ id, target }) => { - const label = tabLabelFor(target) - - return { id, label, target, tooltip: target.kind === 'artifact' ? label : target.path || target.url || label } - }), - [previewTabs] - ) - - const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0] - - useEffect(() => { - if (activeTab && activeTab.id !== activeTabId) { - selectRightRailTab(activeTab.id) - } - }, [activeTab, activeTabId]) - - if (!activeTab) { + // The tab closed while this pane was still mounted (the mirror disposes it a + // tick later). + if (!target) { return null } - const isPreview = activeTab.target.kind === 'url' - return ( - + ) } diff --git a/apps/desktop/src/app/chat/session-drag.ts b/apps/desktop/src/app/chat/session-drag.ts index 16945edbe4..8c2c5fbe2b 100644 --- a/apps/desktop/src/app/chat/session-drag.ts +++ b/apps/desktop/src/app/chat/session-drag.ts @@ -43,6 +43,7 @@ import { $layoutTree, $treeDragging, type DropHint, + isMainStripPane, isSessionStripPane, revealTreePane, SESSION_TILE_DRAG @@ -79,13 +80,16 @@ function snapshotSurfaces(): SurfaceSnapshot[] { })) } -/** A session may land in a zone only if it hosts a chat surface — never the - * sidebar/terminal zones. Returns the pane a stack anchors to. */ -function chatZonePane(groupId: string): null | string { +/** A session may land in any zone hosting a MAIN tile — another chat stack, a + * Browser tile, a page — never the sidebar/terminal zones. Returns the pane a + * stack anchors to, plus whether the zone hosts a CHAT surface (only those + * offer the link-to-composer center; a preview zone's center stacks). */ +function tileZoneHost(groupId: string): { chat: boolean; pane: string } | null { const tree = $layoutTree.get() const panes = tree ? (findGroup(tree, groupId)?.panes ?? []) : [] + const pane = panes.find(isSessionStripPane) ?? panes.find(isMainStripPane) - return panes.find(isSessionStripPane) ?? null + return pane ? { chat: panes.some(isSessionStripPane), pane } : null } /** @@ -105,7 +109,7 @@ export function startSessionDrag( let strips: StripSnapshot[] = [] let surfaces: SurfaceSnapshot[] = [] let composers: ZoneRect[] = [] - let zoneHost = new Map() + let zoneHost = new Map>() // Commit intent, updated per resolved move (the machinery flushes the final // move before commit, so these always match the released-at position). @@ -129,7 +133,7 @@ export function startSessionDrag( strips = snapshotStrips() surfaces = snapshotSurfaces() composers = queryAllVisible('[data-slot="composer-root"]').map(snapRect) - zoneHost = new Map(zones.map(zone => [zone.id, chatZonePane(zone.id)])) + zoneHost = new Map(zones.map(zone => [zone.id, tileZoneHost(zone.id)])) source?.style.setProperty('opacity', '0.45') // The same sentinel the zone overlay + chat surfaces key off — the // whole drop language (sheets, pills, caret, link overlay) lights up. @@ -160,7 +164,7 @@ export function startSessionDrag( // Exclude the tile's OWN tab from the slots so re-dropping it in its // home strip reorders cleanly (a no-op for a sidebar-row drag). const stack = slotBefore(strip.slots, x, `session-tile:${payload.id}`) - split = { anchor: host, before: stack.before, pos: 'center' } + split = { anchor: host.pane, before: stack.before, pos: 'center' } link = null return { kind: 'group', groupId: zone.id, groupIds: [zone.id], pos: 'center', stack } @@ -171,11 +175,16 @@ export function startSessionDrag( const pos = composers.some(rect => rectContains(rect, x, y)) ? 'center' : subZonePosition(zones, zone.id, x, y) const surface = surfaces.find(s => rectContains(s.rect, x, y)) - if (pos === 'center') { + if (pos === 'center' && host.chat) { split = null link = surface?.composerTarget ?? 'main' + } else if (pos === 'center') { + // A preview/page zone has no composer to link to — its center stacks + // the session as a tab, same as dropping on the strip's tail. + split = { anchor: host.pane, pos: 'center' } + link = null } else { - split = { anchor: surface?.anchor ?? 'workspace', pos } + split = { anchor: surface?.anchor ?? host.pane, pos } link = null } diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index b643a31c1b..c66ac36799 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -2,7 +2,6 @@ import { useStore } from '@nanostores/react' import { atom, computed } from 'nanostores' import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react' -import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail' import { SessionStatusDot } from '@/app/chat/session-status-dot' import { PALETTE_AREA, type PaletteContribution, paletteToggle } from '@/app/command-palette/contrib' import { type StatusbarItem } from '@/app/shell/statusbar-controls' @@ -18,7 +17,6 @@ import { bindTreeSideVisibility, declareDefaultTree, dismissTreePane, - dockPaneBeside, isPaneVisible, markCollapsePane, mirrorLayoutTree, @@ -55,7 +53,6 @@ import { SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH } from '@/store/layout' -import { $previewOpenRequest, $previewTabs, closeRightRail } from '@/store/preview' import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share' import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/review' import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session' @@ -63,6 +60,7 @@ import { watchSessionPins } from '@/store/session-pin-sync' import { $statusbarVisible } from '@/store/statusbar-prefs' import type { SessionDragPayload } from '../chat/composer/inline-refs' +import { watchPreviewTiles } from '../chat/preview-tile' import { watchRouteTiles } from '../chat/route-tile' import { startSessionDrag } from '../chat/session-drag' import { @@ -74,7 +72,7 @@ import { import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store' import { $workspaceIsPage } from '../routes' -import { FilesPane, LogsPane, PreviewRailPane, ReviewPaneContent } from './panes' +import { FilesPane, LogsPane, ReviewPaneContent } from './panes' import { ContribWiring, WiredPane } from './wiring' /** @@ -201,24 +199,6 @@ registry.registerMany([ }, render: () => idle() }, - { - id: 'preview', - area: 'panes', - title: 'preview', - // The rail brings its OWN tab strip (per-target tabs with close buttons). - // Exists only while something is previewed — visibility is bound to the - // preview targets below, like every other self-managed surface. dock: - // adoption seed only — dockPaneBeside re-docks it next to files on every - // reveal anyway (position-aware). - data: { - placement: 'right', - dock: { pane: 'files', pos: 'left' }, - width: 'clamp(18rem, 36vw, 32rem)', - minWidth: PREVIEW_RAIL_MIN_WIDTH, - maxWidth: PREVIEW_RAIL_MAX_WIDTH - }, - render: () => idle() - }, { id: 'review', area: 'panes', @@ -347,14 +327,15 @@ registry.registerMany([ // Layout presets — CHAT (main) always dominates. // --------------------------------------------------------------------------- -// The REAL default: sessions left, chat main, and the right sidebars in -// column order main | … | review | preview | file-browser (files outermost, -// preview DIRECTLY left of the file tree). Each is its OWN zone — main -// parity: a file double-click slides the preview open as its own pane beside -// the tree, never as a tab stacked into the files sidebar. Preview/review -// zones collapse to nothing while their pane is hidden (no target / ⌘G off). -// This static spot is just the seed — dockPaneBeside keeps preview adjacent -// to files WHEREVER files moves (see the target listeners below). +// The REAL default: sessions left, chat main, and the right sidebars in column +// order main | … | review | file-browser (files outermost). Each is its OWN +// zone. Review collapses to nothing while its pane is hidden (⌘G off). +// +// Preview tiles are DYNAMIC panes (like session tiles), so no preset names one: +// they're registered by watchPreviewTiles as tabs open, and dockPaneBeside lands +// each one directly beside the file tree wherever that currently lives — so a +// file double-click still slides a preview open as its own pane next to the +// tree, never as a tab stacked into the files sidebar. const DEFAULT_TREE = split( 'row', [ @@ -365,12 +346,8 @@ const DEFAULT_TREE = split( [ split( 'row', - [ - group(['review'], { id: 'grp-review' }), - group(['preview'], { id: 'grp-preview' }), - group(['files'], { id: 'grp-files' }) - ], - [1, 1, 1.2], + [group(['review'], { id: 'grp-review' }), group(['files'], { id: 'grp-files' })], + [1, 1.2], 'spl-rail' ), group(['terminal'], { id: 'grp-terminal' }) @@ -383,16 +360,12 @@ const DEFAULT_TREE = split( 'spl-root' ) -const FOCUS_TREE = split( - 'row', - [group(['sessions']), group(['workspace', 'files', 'preview', 'review', 'terminal'])], - [1, 4.6] -) +const FOCUS_TREE = split('row', [group(['sessions']), group(['workspace', 'files', 'review', 'terminal'])], [1, 4.6]) const TERMINAL_TREE = split( 'column', [ - split('row', [group(['sessions']), group(['workspace']), group(['files', 'preview', 'review'])], [1, 3.2, 1.2]), + split('row', [group(['sessions']), group(['workspace']), group(['files', 'review'])], [1, 3.2, 1.2]), group(['terminal']) ], [3, 1] @@ -402,7 +375,7 @@ const QUAD_TREE = split( 'column', [ split('row', [group(['sessions', 'files']), group(['workspace'])], [1, 3]), - split('row', [group(['terminal']), group(['preview', 'review'])], [1.4, 1]) + split('row', [group(['terminal']), group(['review'])], [1.4, 1]) ], [3, 1] ) @@ -429,6 +402,7 @@ watchContributedPanes() // main. watchSessionTiles() watchRouteTiles() +watchPreviewTiles() // Composer pop-out state is keyed by layout zone, so drop entries for zones the // user has since closed or merged away — otherwise a long-lived install keeps a @@ -544,8 +518,8 @@ bindTreeSideVisibility('right', $fileBrowserOpen, setFileBrowserOpen) const $hasWorkspace = computed($currentCwd, cwd => Boolean(cwd.trim())) // The tree pane's own presence tracks ⌘J directly, not just the column's -// collapse — otherwise revealing a preview (which opens that shared column) -// would drag the tree along with it. See revealPreview. +// collapse — otherwise a pane revealed into that shared column would drag the +// tree along with it. // // Both get a CLOSER and an OPENER. The closer keeps ⌘J/⌘G truthful when the // pane is closed from the tab menu; the opener is its mirror, so bringing the @@ -591,14 +565,6 @@ registry.register( }) ) -// Preview EXISTS only while something is previewed (old-shell semantics: -// closing the last preview tab closes the pane; a new target opens + fronts -// it). Same visibility binding as every other self-managed surface, driven -// by the open tabs instead of a toggle. -const $previewVisible = computed($previewTabs, tabs => tabs.length > 0) - -bindPaneVisibility('preview', $previewVisible, closeRightRail) - // Logs are ⌘K-ONLY chrome: the pane contribution EXISTS only while $logsOpen // is on. Off (the default) keeps logs out of the registry and the tree // entirely — no secondary tab riding the terminal strip, no preset or @@ -696,21 +662,6 @@ registerPaneCloser('files', () => paneRootSide('files') === 'right' ? setFileBrowserOpen(false) : dismissTreePane('files') ) -// A preview target lands NEXT TO the file tree — position-aware: wherever -// files currently lives (default rail, ⌘\-flipped, dragged into a stack), the -// preview zone docks directly beside it. A user who drags the preview pane -// somewhere pins it there instead (until a preset/reset). Then reveal: open -// the side, unhide, front — a NEW target while already visible still fronts. -const revealPreview = () => { - dockPaneBeside('preview', 'files') - revealTreePane('preview') -} - -// Keyed on open REQUESTS, not on the tab list: re-opening a tab that already -// exists must still un-hide and front the pane, and closing one of two tabs -// must not. -$previewOpenRequest.listen(() => revealPreview()) - // --------------------------------------------------------------------------- interface TitlebarSlotProps { diff --git a/apps/desktop/src/app/contrib/panes.tsx b/apps/desktop/src/app/contrib/panes.tsx index 36782638ab..be5df5eaad 100644 --- a/apps/desktop/src/app/contrib/panes.tsx +++ b/apps/desktop/src/app/contrib/panes.tsx @@ -11,14 +11,11 @@ import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import { atom } from 'nanostores' -import type { CSSProperties } from 'react' -import { ChatPreviewRail } from '@/app/chat/right-rail/preview' import { RightSidebarPane } from '@/app/right-sidebar' import { ReviewPane } from '@/app/right-sidebar/review' import type { GroupSetter } from '@/app/shell/group-setter' import type { StatusbarItem } from '@/app/shell/statusbar-controls' -import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar' import type { TitlebarTool } from '@/app/shell/titlebar-controls' import { DecodeText } from '@/components/ui/decode-text' import { ContribBoundary } from '@/contrib/react/boundary' @@ -27,7 +24,7 @@ import { registry } from '@/contrib/registry' import { getLogs } from '@/hermes' import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview' import { cn } from '@/lib/utils' -import { $previewTarget, openPreview } from '@/store/preview' +import { openPreview } from '@/store/preview' import { $currentCwd } from '@/store/session' // --------------------------------------------------------------------------- @@ -72,38 +69,6 @@ export function LogsPane() { * Atom-bridged: this module can't import contrib-wiring (it imports us). */ export const $restartPreviewServer = atom<((url: string, context?: string) => Promise) | null>(null) -export function PreviewRailPane() { - const previewTarget = useStore($previewTarget) - const restartPreviewServer = useStore($restartPreviewServer) - - if (!previewTarget) { - return ( -
-
- - click a file in the files pane -
-
- ) - } - - return ( - // The contrib layout zeroes --titlebar-height (content sits BELOW the - // titlebar, so the real components' clearance padding must collapse) — - // but the rail SIZES its per-file tab strip with that var. Restore the - // real value for this subtree so the tabs always render at full height. -
aside]:pt-0')} - style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties} - > - -
- ) -} - /** Open a file from the tree in the real preview pipeline. */ function previewFile(path: string) { void normalizeOrLocalPreviewTarget(path, $currentCwd.get() || undefined) diff --git a/apps/desktop/src/app/session/hooks/preview-open.test.tsx b/apps/desktop/src/app/session/hooks/preview-open.test.tsx index 97d9415d04..48f1a15a59 100644 --- a/apps/desktop/src/app/session/hooks/preview-open.test.tsx +++ b/apps/desktop/src/app/session/hooks/preview-open.test.tsx @@ -112,6 +112,31 @@ describe('open_preview', () => { expect($previewTabs.get()).toHaveLength(0) }) + // The turn that calls open_preview is often a TILE's session while focus + // sits on main (the user asked, then clicked elsewhere). On-screen is the + // bar — gating on focus made an explicit "open reddit" silently vanish. + it('honors an open from an open tile session even when main holds focus', async () => { + const { $sessionTiles } = await import('@/store/session-states') + const tiles = $sessionTiles.get() + + $sessionTiles.set([{ dir: 'right', runtimeId: 'tile-runtime', storedSessionId: 'stored-tile' }]) + render() + + try { + await act(async () => { + handleEvent({ + payload: { url: '/tmp/from-tile.html' }, + session_id: 'tile-runtime', + type: 'preview.open' + } as unknown as RpcEvent) + }) + + await waitFor(() => expect($previewTarget.get()?.path).toBe('/tmp/from-tile.html')) + } finally { + $sessionTiles.set(tiles) + } + }) + it('opens a second target as its own tab rather than replacing the first', async () => { render() diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.ts b/apps/desktop/src/app/session/hooks/use-preview-routing.ts index 409cb51c05..aadde0b489 100644 --- a/apps/desktop/src/app/session/hooks/use-preview-routing.ts +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.ts @@ -10,8 +10,8 @@ import { progressPreviewServerRestart, requestPreviewReload } from '@/store/preview' -import { $currentCwd } from '@/store/session' -import { $focusedRuntimeId } from '@/store/session-states' +import { $activeSessionId, $currentCwd } from '@/store/session' +import { $focusedRuntimeId, $sessionTiles } from '@/store/session-states' import type { RpcEvent } from '@/types/hermes' type EventHandler = (event: RpcEvent) => void @@ -63,16 +63,24 @@ export function usePreviewRouting({ baseHandleGatewayEvent, currentCwd, requestG if (event.type === 'preview.open') { // Agent-driven open in response to an explicit user request ("show - // cnn.com in the preview pane"). Honor it only for the session the user - // is actually looking at — a background turn must not yank the pane open - // (see desktop AGENTS.md: offer, don't hijack). That session is the - // focused one, which is a TILE's runtime whenever a tile is fronted, not - // the primary chat's. Routes through the same normalizer as the file - // browser so URLs, localhost, and file paths all resolve correctly. + // cnn.com in the preview pane"). Honor it for any session that's ON + // SCREEN — the primary chat or an open tile — not only the focused + // one: the turn's window routing already scoped the event to this + // window, and gating on focus made the open silently vanish whenever + // the user's click had moved focus to a different zone by the time + // the tool ran (an "open reddit" they explicitly asked for). A + // session that is NOT visible anywhere still can't yank the pane + // open (offer, don't hijack). Routes through the same normalizer as + // the file browser so URLs, localhost, and file paths all resolve. const { url, label } = asRecord(event.payload) const target = typeof url === 'string' ? url.trim() : '' - if (target && (!event.session_id || event.session_id === $focusedRuntimeId.get())) { + const onScreen = (sid: string) => + sid === $focusedRuntimeId.get() || + sid === $activeSessionId.get() || + $sessionTiles.get().some(tile => tile.runtimeId === sid) + + if (target && (!event.session_id || onScreen(event.session_id))) { void normalizeOrLocalPreviewTarget(target, $currentCwd.get() || currentCwd || undefined).then(resolved => { if (resolved) { const trimmedLabel = typeof label === 'string' ? label.trim() : '' diff --git a/apps/desktop/src/components/pane-shell/tree/focused-session-tab.test.ts b/apps/desktop/src/components/pane-shell/tree/focused-session-tab.test.ts index 3e85738611..4dddfeaaf7 100644 --- a/apps/desktop/src/components/pane-shell/tree/focused-session-tab.test.ts +++ b/apps/desktop/src/components/pane-shell/tree/focused-session-tab.test.ts @@ -80,4 +80,45 @@ describe('focused chat zone drives the tab verbs', () => { expect(tree.closeFocusedSessionTab()).toBe(false) expect(model.allPaneIds(tree.$layoutTree.get()!)).toContain('workspace') }) + + // Preview/page tiles share `placement: 'main'` but not the session-tile id + // prefix — the generic tab verbs must serve their zones too, or ⌘W over a + // lone Browser tile falls through and empties MAIN instead. + it('⌘W and ⌃Tab serve a zone of preview/page tiles like any tab strip', async () => { + const tree = await import('@/components/pane-shell/tree/store') + const model = await import('@/components/pane-shell/tree/model') + const { registry } = await import('@/contrib/registry') + + for (const id of ['workspace', 'preview-tile:url:x', 'route-tile:/skills']) { + registry.register({ + area: 'panes', + data: id === 'workspace' ? { placement: 'main', uncloseable: true } : { placement: 'main' }, + id, + render: () => null, + title: id + }) + } + + const closed: string[] = [] + + tree.registerPaneCloser('preview-tile:url:x', () => closed.push('preview-tile:url:x')) + tree.declareDefaultTree( + model.split('row', [ + model.group(['workspace'], { active: 'workspace', id: 'grp-main' }), + model.group(['preview-tile:url:x', 'route-tile:/skills'], { active: 'preview-tile:url:x', id: 'grp-view' }) + ]) + ) + tree.noteActiveTreeGroup('grp-view') + + // ⌃Tab cycles within the preview zone, not main's. + expect(tree.cycleTreeTabInFocusedZone(1)).toBe('route-tile:/skills') + expect(tree.$layoutTree.get() && model.findGroup(tree.$layoutTree.get()!, 'grp-view')?.active).toBe( + 'route-tile:/skills' + ) + + // ⌘W closes the zone's active tab through its registered closer. + tree.cycleTreeTabInFocusedZone(1) + expect(tree.closeFocusedSessionTab()).toBe(true) + expect(closed).toEqual(['preview-tile:url:x']) + }) }) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts index 8cfe0560eb..a16ad0e67a 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.test.ts @@ -9,13 +9,12 @@ describe('forceLoneHeaderForPanes', () => { const noCollapse = () => false - it('forces a header for session-tile ids even without registered chrome', () => { - expect(forceLoneHeaderForPanes(['session-tile:abc'], () => ({}), noCollapse)).toBe(true) - }) - + // Every mirrored tile (session / page / preview) is a closeable `main` pane, so + // dragging one into a zone of its own must keep its tab — it used to strand a + // preview headerless, with nothing to grab and no ✕. it('forces a header for closeable placement:main panes', () => { - expect(forceLoneHeaderForPanes(['workspace'], chrome('main', true), noCollapse)).toBe(false) - expect(forceLoneHeaderForPanes(['some-page'], chrome('main', false), noCollapse)).toBe(true) + expect(forceLoneHeaderForPanes(['preview-tile:url:x'], chrome('main'), noCollapse)).toBe(true) + expect(forceLoneHeaderForPanes(['session-tile:abc'], chrome('main'), noCollapse)).toBe(true) }) it('forces a header for a lone collapse tool pane', () => { @@ -31,4 +30,8 @@ describe('forceLoneHeaderForPanes', () => { it('leaves a lone uncloseable workspace headerless', () => { expect(forceLoneHeaderForPanes(['workspace'], chrome('main', true), noCollapse)).toBe(false) }) + + it('leaves standing side chrome (files / sessions) headerless', () => { + expect(forceLoneHeaderForPanes(['files'], chrome('right'), noCollapse)).toBe(false) + }) }) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts index 1a0ab4c073..4fe0d97757 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/lone-header.ts @@ -3,8 +3,9 @@ * * Default: a single pane isn't a "tab", so the header auto-hides. Exceptions * force it on so a closeable surface never becomes an unclosable dead zone: - * - session tiles (`session-tile:*`) — even before chrome registers - * - any closeable `placement: 'main'` contribution + * - a closeable `placement: 'main'` pane — every mirrored TILE (a session, a + * page, a preview) is one, so dragging a tile into a zone of its own keeps + * its tab and its ✕ * - a collapse tool panel dragged into its own zone */ @@ -18,10 +19,8 @@ export function forceLoneHeaderForPanes( chromeOf: (id: string) => LoneHeaderChrome, isCollapsePane: (id: string) => boolean ): boolean { - if (shown.some(id => id.startsWith('session-tile:'))) { - return true - } - + // "This pane can be closed, so it must expose the ✕." Only the uncloseable + // workspace is exempt; standing side chrome (files / sessions) isn't 'main'. if ( shown.some(id => { const chrome = chromeOf(id) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts index 386036d2e1..46b49ec514 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts +++ b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts @@ -9,6 +9,7 @@ import type * as React from 'react' +import type { PaneStripTool } from '@/components/ui/pane-tab' import type { Contribution } from '@/contrib/types' import type { GroupNode, LayoutNode } from '../model' @@ -80,6 +81,12 @@ interface PaneChrome extends PaneSizing { * the tab and the sidebar row render status/color from the ONE primitive * (self-subscribing — it updates without the strip re-registering). */ tabLead?: () => React.ReactNode + /** Glyph buttons this pane contributes to the strip, rendered after the last + * tab (where "+" sits) while the pane is ACTIVE — controls that act on the + * pane, not on any one tab: a preview's console / DevTools toggles. DATA, not + * markup: `PaneStripGlyph` owns the styling so every glyph on every strip + * matches. Read on each render, so a live store drives `active`/`disabled`. */ + stripTools?: () => readonly PaneStripTool[] } export const paneChrome = (c: Contribution | undefined) => (c?.data ?? {}) as PaneChrome diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 0694c6c3af..e356aacac0 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -16,7 +16,15 @@ import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components import { Codicon } from '@/components/ui/codicon' import { DecodeText } from '@/components/ui/decode-text' import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance' -import { PANE_TAB_STRIP_LINE_LEFT, PANE_TAB_STRIP_LINE_RIGHT, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab' +import { + PANE_TAB_STRIP_LINE_LEFT, + PANE_TAB_STRIP_LINE_RIGHT, + PaneStripGlyph, + PaneTab, + paneTabCloseItems, + PaneTabLabel, + PaneTabStrip +} from '@/components/ui/pane-tab' import { ContribBoundary } from '@/contrib/react/boundary' import { useContributions } from '@/contrib/react/use-contributions' import { useI18n } from '@/i18n' @@ -32,6 +40,7 @@ import { $narrowViewport, $newSessionTabAction, $panesWithCloser, + $stripToolsRevision, $treeDragging, $treePaneEpochs, activateTreePane, @@ -102,39 +111,22 @@ function ZoneMenu({ // dead action, and the counts describe the chip actually clicked. const items = (kit: MenuKit) => { const paneId = closable?.() - const targets = treeTabCloseTargets(targetPane()) + const targetId = targetPane() return ( <> {renderActionItem(kit, { icon: 'refresh', label: t.zones.reload, - onSelect: () => reloadTreePane(targetPane()) + onSelect: () => reloadTreePane(targetId) })} - {paneId !== undefined && - renderActionItem(kit, { - icon: 'close', - label: t.common.close, - onSelect: () => closeTabPane(paneId) - })} - {renderActionItem(kit, { - disabled: !targets.others, - icon: 'close-all', - label: t.zones.closeOthers, - onSelect: () => closeOtherTreeTabs(targetPane()) - })} - {renderActionItem(kit, { - disabled: !targets.right, - icon: 'arrow-right', - label: t.zones.closeToRight, - onSelect: () => closeTreeTabsToRight(targetPane()) - })} - {renderActionItem(kit, { - disabled: !targets.all, - icon: 'clear-all', - label: t.zones.closeAll, - onSelect: () => closeAllTreeTabs(targetPane()) + {paneTabCloseItems(kit, { + counts: treeTabCloseTargets(targetId), + onClose: paneId !== undefined ? () => closeTabPane(paneId) : undefined, + onCloseAll: () => closeAllTreeTabs(targetId), + onCloseOthers: () => closeOtherTreeTabs(targetId), + onCloseToRight: () => closeTreeTabsToRight(targetId) })} {renderActionItem(kit, { @@ -200,6 +192,9 @@ export function TreeGroup({ // Reload epochs: only an explicit tab-menu Reload writes here, so this // subscription costs nothing on a normal render. const paneEpochs = useStore($treePaneEpochs) + // Re-read the active pane's contributed strip glyphs when their state changes + // (a toggle flipped, a DevTools handle registered). + useStore($stripToolsRevision) const paneFor = (id: string) => panes.find(p => p.id === id) @@ -410,16 +405,13 @@ export function TreeGroup({ )} - {/* Header: the file-preview tab strip (PaneTab), one shared component. */} + {/* Header: the shared pane tab strip (PaneTabStrip + PaneTab). */} {headerVisible && ( -
// Tap the header to collapse to it / expand back — the DetailPane // / sidebar-section gesture (never for the main zone). Double-tap @@ -435,164 +427,166 @@ export function TreeGroup({ } ref={stripRef} style={{ cursor: 'grab' }} - > -
- {shown.map(paneId => { - const isActive = paneId === activeId && !node.minimized - const chrome = paneChrome(paneFor(paneId)) - const closeable = closeableTab(paneId) - const title = paneFor(paneId)?.title ?? paneId - const isSelected = tabSelection?.groupId === node.id && tabSelection.ids.has(paneId) - - const tab = ( - closeTab(paneId) : undefined} - onPointerDown={e => { - // Chrome's tab-selection grammar, ahead of activate/drag: - // Shift-click ranges from the anchor, ⌥-click (Ctrl-click - // off-Mac) toggles. Neither activates nor starts a drag — - // the press IS the selection edit. ⌘-click stays close - // (PaneTab claims it first) and ⌃-click stays the macOS - // context menu. - if (e.button === 0 && e.shiftKey) { - e.preventDefault() - e.stopPropagation() - selectTabRange(node.id, shown, paneId, activeId) - - return - } - - if (isToggleSelectClick(e)) { - e.preventDefault() - e.stopPropagation() - toggleTabSelected(node.id, paneId, activeId) - - return - } - - // Tabs ACTIVATE (restoring a collapsed group). Minimize - // lives on the chevron / single-pane label — overloading - // the active tab made double-click a minimize/restore/hide - // lottery. A plain click also collapses any multi-tab - // selection back to the one tab (Chrome semantics). - const onTap = () => { - clearTabSelection() - - if (node.minimized) { - restoreTreePane(paneId) - } - - activateTreePane(node.id, paneId) - } - - // Claim the press so the STRIP's own pane-drag handler - // (parent onPointerDown) can't also fire. startPaneDrag - // does this internally; the session drag (shared with - // sidebar rows) doesn't, so do it here for both paths. - if (e.button === 0) { - e.preventDefault() - e.stopPropagation() - } - - // Dragging a SELECTED tab carries the whole selection as - // one block through the generic pane move — a multi-tab - // drag outranks the pane's own tab drag (the session drop - // language is single-session). - const dragSelection = selectionFor(node.id, shown, paneId) - - if (dragSelection) { - startPaneDrag( - paneId, - e, - onTap, - stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined, - hideHeaderDoubleTap, - t.zones.tabCount(dragSelection.length), - dragSelection - ) - - return - } - - // A pane may own its tab drag (a session tab speaks the - // session drop language — link/stack/split); `false` defers - // to the generic pane move (the workspace tab on a fresh - // draft has no session to link). - if (!chrome.tabDrag?.(e, onTap, hideHeaderDoubleTap)) { - startPaneDrag( - paneId, - e, - onTap, - stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined, - hideHeaderDoubleTap, - title - ) - } - }} - role="tab" - selected={isSelected} - style={{ cursor: 'grab' }} + trailing={ + <> + {minimizable && ( + + )} + + + } + > + {shown.map(paneId => { + const isActive = paneId === activeId && !node.minimized + const chrome = paneChrome(paneFor(paneId)) + const closeable = closeableTab(paneId) + const title = paneFor(paneId)?.title ?? paneId + const isSelected = tabSelection?.groupId === node.id && tabSelection.ids.has(paneId) - // A pane may wrap ITS tab in a domain menu (session verbs on a - // tile tab); the wrapper needs the key since it's the root. - return {chrome.tabWrap ? chrome.tabWrap(tab) : tab} - })} - - {/* Plain "+" after the last tab of a CHAT strip (the workspace - zone, or any zone holding session tabs) — always shown, no - tab/button chrome, just the glyph. Creates a new session tab - (mirrors ⌘T) via the app-registered action; the pointerdown - focuses this zone first, so the tab lands in THIS strip. - Hidden when unwired or the zone is minimized. */} - {shown.some(isSessionStripPane) && newSessionTabAction && !node.minimized && ( - - )} -
- {minimizable && ( - + } + label={t.zones.newSessionTab} + onSelect={() => newSessionTabAction()} + /> + )} - -
+
)} diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index ab6284a9c3..53286d4c41 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -263,6 +263,16 @@ export function registerLayoutResetHandler(fn: () => void): () => void { * click lands on a non-focusable surface). Tracked by trackActiveTreeGroup. */ export const $activeTreeGroup = atom(null) +/** Bumped whenever a pane's contributed STRIP TOOLS change shape (a toggle + * flipped, a handle registered). The strip reads `stripTools()` during render, + * so it needs one signal to re-read — generic on purpose: the tree knows + * nothing about what any pane's tools mean. */ +export const $stripToolsRevision = atom(0) + +export function invalidateStripTools() { + $stripToolsRevision.set($stripToolsRevision.get() + 1) +} + /** Record the interacted zone (pointerdown / focusin). Idempotent. */ export function noteActiveTreeGroup(groupId: null | string) { if (groupId !== $activeTreeGroup.get()) { @@ -357,16 +367,27 @@ const isUncloseablePane = (paneId: string): boolean => (registry.getArea('panes').find(c => c.id === paneId)?.data as { uncloseable?: boolean } | undefined)?.uncloseable ) -/** A pane that belongs to a CHAT tab strip — the workspace or a session tile. */ +/** A pane that belongs to a CHAT tab strip — the workspace or a session tile. + * Chat surfaces only: this gates where a session may DOCK (drops, ⌘T's "+"), + * not which zones the generic tab verbs serve — that's `isMainStripPane`. */ export const isSessionStripPane = (paneId: string): boolean => paneId === 'workspace' || paneId.startsWith('session-tile:') -/** The zone the session-tab verbs (⌘W / ⌘T / ⌘⇧T / the strip's "+") act on: - * the first of hovered / focused / workspace that hosts a chat strip. Same - * ladder ⌘1…⌘9 indexes, so the number keys and the tab verbs can't disagree - * about which strip is "the" strip. A target parked in the sidebar / terminal - * / files must NOT retarget them — those zones fall through to main rather - * than letting ⌘W close the file tree. */ +/** Any MAIN-placement tile's pane — a session, a page, a preview. The zones + * these stack into are real tab strips, so the generic tab verbs (⌘W, ⌃Tab) + * must serve them all; keying on the session prefix left ⌘W and ⌃Tab dead + * over a Browser/page zone while ⌘1…⌘9 worked. Standing side chrome (files / + * sessions / terminal) isn't 'main', so those zones still fall through. */ +export const isMainStripPane = (paneId: string): boolean => + (registry.getArea('panes').find(c => c.id === paneId)?.data as { placement?: string } | undefined)?.placement === + 'main' + +/** The zone the session-tab verbs (⌘T / ⌘⇧T / the strip's "+") act on: the + * first of hovered / focused / workspace that hosts a chat strip. Same ladder + * ⌘1…⌘9 indexes, so the number keys and the tab verbs can't disagree about + * which strip is "the" strip. A target parked in the sidebar / terminal / + * files must NOT retarget them — those zones fall through to main rather + * than letting ⌘T dock a session into the file tree. */ function focusedSessionGroup(): GroupNode | null { return tabTargetGroup(group => group.panes.some(isSessionStripPane)) } @@ -386,11 +407,15 @@ export function focusedSessionTabAnchor(): null | string { return active && isSessionStripPane(active) ? active : (group.panes.find(isSessionStripPane) ?? null) } -/** ⌘W: close the FOCUSED chat zone's active tab, unless it's the uncloseable - * workspace itself. Returns false when there's nothing to close, so ⌘W stays a - * no-op — it never closes the window. */ +/** ⌘W: close the FOCUSED tile zone's active tab, unless it's the uncloseable + * workspace itself. Any main-strip zone qualifies — a session stack, a lone + * Browser/page tile — while side chrome (files / terminal) in a zone of its + * own falls through to its own rung. Keying eligibility on the chat strip + * made ⌘W over a lone preview zone fall all the way through and empty the + * MAIN chat instead. Returns false when there's nothing to close, so ⌘W + * stays a no-op — it never closes the window. */ export function closeFocusedSessionTab(): boolean { - const active = focusedSessionGroup()?.active + const active = tabTargetGroup(group => group.panes.some(isMainStripPane))?.active if (!active || isUncloseablePane(active)) { return false @@ -568,15 +593,16 @@ export function activateTreeTabSlot(slot: number): null | string { } /** ⌃Tab / ⌃⇧Tab: cycle the target zone's *visible* tabs (wrapping) — the first - * of hovered / focused / workspace that is a chat strip with ≥2 shown tabs. - * Returns the activated pane id (see `activateTreeTabSlot` — landing on the - * workspace under a full page must route back to the chat), or null so the - * caller falls back to the recent-session switcher when no zone qualifies. */ + * of hovered / focused / workspace that is a tile strip with ≥2 shown tabs + * (any main-placement tenant: sessions, pages, previews). Returns the + * activated pane id (see `activateTreeTabSlot` — landing on the workspace + * under a full page must route back to the chat), or null so the caller + * falls back to the recent-session switcher when no zone qualifies. */ export function cycleTreeTabInFocusedZone(direction: 1 | -1): null | string { const group = tabTargetGroup(candidate => { const shown = shownPanesInGroup(candidate) - return shown.length >= 2 && shown.some(isSessionStripPane) + return shown.length >= 2 && shown.some(isMainStripPane) }) if (!group) { @@ -595,7 +621,7 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): null | string { // Cycling onto a session/main tab must surface the name card — a zone that // was double-tap-hidden stays headerless otherwise ("the one that cycles // never gets it"). - if (isSessionStripPane(nextId)) { + if (isMainStripPane(nextId)) { setTreeGroupHeaderHidden(group.id, false) } diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index 906b0d1f80..9234aebd55 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -1,5 +1,9 @@ import * as React from 'react' +import { type MenuKit, renderActionItem } from '@/components/ui/actions-menu' +import { Button } from '@/components/ui/button' +import { Tip } from '@/components/ui/tooltip' +import { translateNow } from '@/i18n' import { isMetaClose, middleClickHandlers } from '@/lib/middle-click' import { cn } from '@/lib/utils' @@ -177,3 +181,149 @@ export const PaneTabLabel = React.forwardRef(fun ) }) + +interface PaneTabStripProps extends React.ComponentProps<'div'> { + /** The scrolling tab list — receives `role="tablist"`. */ + children: React.ReactNode + /** Ref on the scroller itself, for `useActiveTabVisible`. */ + listRef?: React.Ref + /** Non-scrolling trailing chrome pinned to the right (the minimize chevron). */ + trailing?: React.ReactNode +} + +/** + * The horizontal tab bar every strip in the app sits in. Owns the bar's height + * and surface, the scroll behaviour (hidden scrollbars, contained overscroll), + * and the pinned trailing slot — so a new strip inherits all of it instead of + * re-deriving the geometry and drifting out of alignment. + * + * Tabs go in `children` as `PaneTab`s; per-strip extras (drag handlers, + * `data-zone-tabstrip`, drop carets) ride on the usual div props. + */ +export const PaneTabStrip = React.forwardRef(function PaneTabStrip( + { children, className, listRef, trailing, ...props }, + ref +) { + return ( +
+
+ {children} +
+ {trailing} +
+ ) +}) + +/** A glyph button on a tab strip: the "+" and anything a pane contributes (a + * preview's console / DevTools). Callers pass DATA, never classes — the same + * contract as `TitlebarTool`, so every glyph on every strip matches. */ +export interface PaneStripTool { + active?: boolean + disabled?: boolean + icon: React.ReactNode + id: string + /** Tooltip text and accessible name. */ + label: string + onSelect: () => void +} + +/** + * Renders one `PaneStripTool` through the app's `Button` + `Tip` primitives, the + * way `TitlebarToolButton` does: ghost variant, no active background — state + * reads from the glyph's own opacity, with `aria-pressed` carrying it for a11y. + * + * Pointerdown is claimed here so a click can never also activate or drag the + * zone behind the strip. + */ +export function PaneStripGlyph({ active, disabled, icon, label, onSelect }: Omit) { + return ( + + + + ) +} + +/** Close-verb enablement for `paneTabCloseItems` — how many tabs each verb hits. */ +export interface PaneTabCloseCounts { + all: number + others: number + right: number +} + +interface PaneTabCloseItemsOptions { + counts: PaneTabCloseCounts + /** Omit to hide Close entirely (an uncloseable tab shows no dead action). */ + onClose?: () => void + onCloseAll: () => void + onCloseOthers: () => void + onCloseToRight: () => void +} + +/** + * The four close verbs every tab menu offers — Close / others / to the right / + * all — so a tab answers a right-click the same way wherever it lives. No ⌘W + * hint on Close: the keybind closes the FOCUSED zone's active tab, so it would + * be a lie on the inactive tab the user actually right-clicked. + */ +export function paneTabCloseItems( + kit: MenuKit, + { counts, onClose, onCloseAll, onCloseOthers, onCloseToRight }: PaneTabCloseItemsOptions +) { + return ( + <> + {onClose && + renderActionItem(kit, { + icon: 'close', + label: translateNow('common.close'), + onSelect: onClose + })} + {renderActionItem(kit, { + disabled: !counts.others, + icon: 'close-all', + label: translateNow('zones.closeOthers'), + onSelect: onCloseOthers + })} + {renderActionItem(kit, { + disabled: !counts.right, + icon: 'arrow-right', + label: translateNow('zones.closeToRight'), + onSelect: onCloseToRight + })} + {renderActionItem(kit, { + disabled: !counts.all, + icon: 'clear-all', + label: translateNow('zones.closeAll'), + onSelect: onCloseAll + })} + + ) +} diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index cf7302e1da..65ba2f448d 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2147,10 +2147,6 @@ export const ar = defineLocale({ }, preview: { tab: 'معاينة', - closeTab: label => `إغلاق ${label}`, - closeOthers: 'إغلاق الأخرى', - closeToRight: 'إغلاق ما على اليمين', - closeAll: 'إغلاق الكل', closePane: 'إغلاق جزء المعاينة', loading: 'جار تحميل المعاينة', unavailable: 'المعاينة غير متاحة', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 47727de87e..ee3eeed7df 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2581,10 +2581,6 @@ export const en: Translations = { preview: { tab: 'Preview', - closeTab: label => `Close ${label}`, - closeOthers: 'Close others', - closeToRight: 'Close to the right', - closeAll: 'Close all', closePane: 'Close preview pane', loading: 'Loading preview', unavailable: 'Preview unavailable', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 1409940479..1d8ebb907e 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2405,10 +2405,6 @@ export const ja = defineLocale({ preview: { tab: 'プレビュー', - closeTab: label => `${label} を閉じる`, - closeOthers: '他を閉じる', - closeToRight: '右側を閉じる', - closeAll: 'すべて閉じる', closePane: 'プレビューペインを閉じる', loading: 'プレビューを読み込み中', unavailable: 'プレビューは利用できません', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index d73175787e..d29f3d38e0 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2180,10 +2180,6 @@ export interface Translations { preview: { tab: string - closeTab: (label: string) => string - closeOthers: string - closeToRight: string - closeAll: string closePane: string loading: string unavailable: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 7996bd74fd..b614fe8abb 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2328,10 +2328,6 @@ export const zhHant = defineLocale({ preview: { tab: '預覽', - closeTab: label => `關閉 ${label}`, - closeOthers: '關閉其他', - closeToRight: '關閉右側', - closeAll: '全部關閉', closePane: '關閉預覽窗格', loading: '正在載入預覽', unavailable: '預覽不可用', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 6a7231aa0d..bb063e5752 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2762,10 +2762,6 @@ export const zh: Translations = { preview: { tab: '预览', - closeTab: label => `关闭 ${label}`, - closeOthers: '关闭其他', - closeToRight: '关闭右侧', - closeAll: '全部关闭', closePane: '关闭预览面板', loading: '正在加载预览', unavailable: '预览不可用', diff --git a/apps/desktop/src/store/artifacts.test.ts b/apps/desktop/src/store/artifacts.test.ts index e1554c8127..4a0933086e 100644 --- a/apps/desktop/src/store/artifacts.test.ts +++ b/apps/desktop/src/store/artifacts.test.ts @@ -11,8 +11,7 @@ import { selectArtifactVersion, upsertArtifact } from './artifacts' -import { $rightRailActiveTabId, PREVIEW_PANE_ID } from './layout' -import { $paneOpen } from './panes' +import { $rightRailActiveTabId } from './layout' import { $previewTabs, closeRightRail, closeRightRailTab } from './preview' import { $activeSessionId, $selectedStoredSessionId } from './session' @@ -94,7 +93,6 @@ describe('artifacts store', () => { expect(tab.target).toMatchObject({ kind: 'artifact', label: 'Pomodoro Timer', url: result.artifactId }) expect($rightRailActiveTabId.get()).toBe(tab.id) - expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true) closeRightRailTab(tab.id) diff --git a/apps/desktop/src/store/layout.ts b/apps/desktop/src/store/layout.ts index c2debd8a72..aed28f95dd 100644 --- a/apps/desktop/src/store/layout.ts +++ b/apps/desktop/src/store/layout.ts @@ -41,7 +41,6 @@ export const FILE_BROWSER_PANE_ID = 'file-browser' /** The file tree's id in the LAYOUT TREE — distinct from the pane-state id * above, which keys its open/width record. Toggles need both. */ export const FILES_PANE_ID = 'files' -export const PREVIEW_PANE_ID = 'preview' /** Every rail tab is a preview of something, namespaced by what backs it: a * path on disk, a live URL, or an id into the in-memory artifact registry. */ @@ -49,7 +48,6 @@ export type RightRailTabId = `artifact:${string}` | `file:${string}` | `url:${st ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true }) ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false }) -ensurePaneRegistered(PREVIEW_PANE_ID, { open: true }) export const $sidebarOpen: ReadableAtom = computed( $paneStates, diff --git a/apps/desktop/src/store/preview.test.ts b/apps/desktop/src/store/preview.test.ts index 9622e557d0..9809a7e44b 100644 --- a/apps/desktop/src/store/preview.test.ts +++ b/apps/desktop/src/store/preview.test.ts @@ -1,14 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { $rightRailActiveTabId, PREVIEW_PANE_ID } from './layout' -import { $paneOpen } from './panes' +import { $rightRailActiveTabId } from './layout' import { $previewServerRestart, $previewServerRestartStatus, $previewTabs, $previewTarget, beginPreviewServerRestart, - closeActiveRightRailTab, closePreviewForSource, closeRightRail, closeRightRailTab, @@ -58,7 +56,6 @@ describe('preview store', () => { it('opens the pane and fronts the new tab', () => { openPreview(fileTarget('/work/demo.html'), 'tool-result') - expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true) expect($rightRailActiveTabId.get()).toBe('file:file:///work/demo.html') expect($previewTarget.get()?.path).toBe('/work/demo.html') }) @@ -71,6 +68,20 @@ describe('preview store', () => { expect($previewTabs.get().map(tab => tab.target.kind)).toEqual(['file', 'url', 'artifact']) }) + // The Browser is a SINGLETON: the tab names the surface, not the page, so a + // second URL navigates the browser it already has instead of stacking a + // second Browser tab beside the first. + it('keeps one Browser tab — a second url swaps its target instead of adding a tab', () => { + openPreview(urlTarget('https://news.ycombinator.com'), 'tool-result') + openPreview(urlTarget('https://www.reddit.com'), 'tool-result') + + const urlTabs = $previewTabs.get().filter(tab => tab.target.kind === 'url') + + expect(urlTabs).toHaveLength(1) + expect(urlTabs[0].target.url).toBe('https://www.reddit.com') + expect($rightRailActiveTabId.get()).toBe(urlTabs[0].id) + }) + it('re-fronts an existing tab instead of duplicating it, refreshing its target', () => { openPreview({ ...fileTarget('/work/demo.html'), label: 'old' }, 'file-browser') openPreview({ ...fileTarget('/work/demo.html'), label: 'new' }, 'file-browser') @@ -89,23 +100,23 @@ describe('preview store', () => { expect($previewTarget.get()?.renderMode).toBe('preview') }) - it('falls back to a neighbouring tab when the active one closes, and shuts the pane on the last', () => { + it('falls back to a neighbouring tab when the active one closes, and clears the selection on the last', () => { openPreview(fileTarget('/work/one.html'), 'file-browser') openPreview(fileTarget('/work/two.html'), 'file-browser') closeRightRailTab(previewTabId(fileTarget('/work/two.html'))) expect($previewTarget.get()?.path).toBe('/work/one.html') - expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true) - expect(closeActiveRightRailTab()).toBe(true) + closeRightRailTab(previewTabId(fileTarget('/work/one.html'))) expect($previewTarget.get()).toBeNull() expect($rightRailActiveTabId.get()).toBeNull() - expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(false) }) - it('reports nothing to close when the rail is empty, so the shortcut falls through', () => { - expect(closeActiveRightRailTab()).toBe(false) + it('ignores a close for a tab that is not open, so the shortcut falls through', () => { + closeRightRailTab('file:file:///nowhere.html') + + expect($previewTabs.get()).toHaveLength(0) }) it('closes by the raw source the composer rows were handed', () => { diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts index 109cfa1c1e..8391c2d114 100644 --- a/apps/desktop/src/store/preview.ts +++ b/apps/desktop/src/store/preview.ts @@ -3,8 +3,7 @@ import { atom, computed } from 'nanostores' import { persistentAtom } from '@/lib/persisted' import { normalize } from '@/lib/text' -import { $rightRailActiveTabId, PREVIEW_PANE_ID, type RightRailTabId, selectRightRailTab } from './layout' -import { setPaneOpen } from './panes' +import { $rightRailActiveTabId, type RightRailTabId, selectRightRailTab } from './layout' /** * PREVIEW RAIL — one list of tabs, one way in. @@ -96,8 +95,16 @@ function isPreviewTab(value: unknown): value is PreviewTab { export const $previewTabs = persistentAtom(TABS_STORAGE_KEY, [], { decode: raw => { const parsed = JSON.parse(raw) as unknown + const tabs = Array.isArray(parsed) ? parsed.filter(isPreviewTab) : [] - return Array.isArray(parsed) ? parsed.filter(isPreviewTab) : [] + // One Browser: rekey restored URL tabs onto the singleton id (rows written + // before the id existed carried one id per address) and keep only the + // LAST — the most recently opened page is the one the browser shows. + const lastUrl = tabs.findLast(tab => tab.target.kind === 'url') + + return tabs + .filter(tab => tab.target.kind !== 'url' || tab === lastUrl) + .map(tab => (tab.target.kind === 'url' ? { ...tab, id: previewTabId(tab.target) } : tab)) }, // Inline bytes are not restorable. Strip them from images, and skip remote // HTML and artifact tabs that cannot render without their in-memory payload. @@ -147,14 +154,18 @@ export const $previewTarget = computed( export const $previewTabSources = computed($previewTabs, tabs => tabs.map(tab => tab.target.source)) export const $previewReloadRequest = atom(0) -/** Bumped by every `openPreview` call so the layout can reveal the pane even - * when the tab already existed (re-opening a hidden pane must still show it). */ -export const $previewOpenRequest = atom(0) export const $previewServerRestart = atom(null) export const $previewServerRestartStatus = computed($previewServerRestart, restart => restart?.status ?? 'idle') +/** The one Browser tab's id. URL targets all share it: the tab names the + * SURFACE (Browser), not the page, so opening a second URL navigates the + * browser it already has — re-front the tab, swap its target, and the pane + * rebuilds its webview against the new url. Files and artifacts stay keyed + * by identity; only the web surface is a singleton. */ +const BROWSER_TAB_ID: RightRailTabId = 'url:browser' + export function previewTabId(target: PreviewTarget): RightRailTabId { - return `${target.kind}:${target.url}` + return target.kind === 'url' ? BROWSER_TAB_ID : `${target.kind}:${target.url}` } // Browsing files is "peek at the source"; a tool or an explicit link handing @@ -171,9 +182,9 @@ function previewTargetForSource(target: PreviewTarget, source: PreviewRecordSour return { ...target, renderMode: isFilePreviewSource(source) ? 'source' : 'preview' } } -/** Open (or re-front) the rail tab for `target`. Re-opening an existing tab - * refreshes its target so a stale label/path can't outlive the thing it - * points at. The only way anything reaches the preview rail. */ +/** Open (or re-front) the tab for `target`. Re-opening an existing tab refreshes + * its target so a stale label/path can't outlive the thing it points at. The + * only way anything reaches a preview. */ export function openPreview(target: PreviewTarget, source: PreviewRecordSource = 'manual') { const resolved = previewTargetForSource(target, source) const id = previewTabId(resolved) @@ -182,12 +193,10 @@ export function openPreview(target: PreviewTarget, source: PreviewRecordSource = const tab: PreviewTab = { id, target: resolved } $previewTabs.set(index === -1 ? [...current, tab] : current.map((item, i) => (i === index ? tab : item))) - setPaneOpen(PREVIEW_PANE_ID, true) selectRightRailTab(id) - $previewOpenRequest.set($previewOpenRequest.get() + 1) } -export function closeRightRailTab(tabId: RightRailTabId) { +export function closeRightRailTab(tabId: string) { const current = $previewTabs.get() const index = current.findIndex(tab => tab.id === tabId) @@ -204,7 +213,7 @@ export function closeRightRailTab(tabId: RightRailTabId) { } if (next.length === 0) { - setPaneOpen(PREVIEW_PANE_ID, false) + selectRightRailTab(null) } } @@ -231,50 +240,10 @@ export function closeArtifactPreviewTabs() { } } -/** Close the tab the right rail is actually showing. Returns false when nothing - * closed, so ⌘W can fall through to the next handler. */ -export function closeActiveRightRailTab(): boolean { - const tab = activePreviewTab() - - if (!tab) { - return false - } - - closeRightRailTab(tab.id) - - return true -} - -/** Close every rail tab except `keepId`, then make `keepId` active. */ -export function closeOtherRightRailTabs(keepId: RightRailTabId) { - for (const tab of $previewTabs.get()) { - if (tab.id !== keepId) { - closeRightRailTab(tab.id) - } - } - - selectRightRailTab(keepId) -} - -/** Close every rail tab positioned after `tabId` (VS Code's "Close to the Right"). */ -export function closeRightRailTabsToRight(tabId: RightRailTabId) { - const tabs = $previewTabs.get() - const index = tabs.findIndex(tab => tab.id === tabId) - - if (index === -1) { - return - } - - for (const tab of tabs.slice(index + 1)) { - closeRightRailTab(tab.id) - } -} - -/** Close every tab so the rail pane unmounts. */ +/** Close every tab so the rail's panes leave the tree. */ export function closeRightRail() { $previewTabs.set([]) selectRightRailTab(null) - setPaneOpen(PREVIEW_PANE_ID, false) } export function requestPreviewReload() {