From c8648278c38473e2dc2a79f2064c33b79d31ea42 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 5 Aug 2026 10:22:58 -0600 Subject: [PATCH] In-app browser and previews are real layout-tree tabs (#77705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): shared pane-strip primitives — one bar, one glyph, one close menu The zone header hand-rolled its tab bar, its close-verb context menu, and the bare-glyph "+" inline; the preview rail kept a second copy of all three. Extract PaneTabStrip (the bar), PaneStripGlyph/PaneStripTool (glyph buttons as data, titlebar-tool style), and paneTabCloseItems (the four close verbs) into the pane-tab primitives, and render the zone header through them. Panes contribute strip glyphs via PaneChrome.stripTools; $stripToolsRevision tells the strip to re-read. * refactor(desktop): preview tabs are layout-tree tiles like session and page tiles The in-app browser / preview rail carried its own tab strip beside the zone's own — a second bar at a different height with its own close menu, label casing, ⌘W rung, and welded to the file browser's zone so ⌘J toggled it away. It predated the layout tree. $previewTabs now mirrors into pane contributions through the same paneMirror session and route tiles use, so a preview tab IS a zone tab: one strip, drag/ stack/split, the shared close verbs, plain ⌘W, its own zone docked beside main. URL tabs are titled Browser (the tab names the surface, not the page); files keep their filename and a file-type lead glyph. Deleted with the rail: the preview pane contribution + PREVIEW_PANE_ID + its visibility binding, the 'preview' placements in the default tree and presets, the ⌘W rail rung, the reveal listener, and the preview.close* i18n keys (copies of zones.*). lone-header now keys on "closeable placement:main" instead of the session-tile: id prefix, so any tile dragged into its own zone keeps its tab. * fix(desktop): preview console/DevTools live on the strip, and DevTools tells the truth The two toggles were titlebar tools — far from the preview they act on and one ambiguous global pair once two previews were open. They're strip glyphs now, contributed per-tab as PaneStripTool data with real tooltips: the console store is cached by tab id so the glyph and the panel read the same logs, and the pane registers a DevTools handle for its tab. DevTools active state was also a lie: it tracked our click handler, so closing the DevTools window itself left the glyph stuck on. The webview's devtools-opened/closed events drive it now. * fix(desktop): ⌘W and ⌃Tab work over preview and page zones The generic tab verbs keyed zone eligibility on the CHAT strip (workspace / session-tile: ids), so a zone holding only a Browser or page tile was invisible to them: ⌃Tab skipped it, and ⌘W fell through the chat rung and emptied the MAIN chat while you were looking at a preview. ⌘1…⌘9 already worked — the verbs disagreed about what counts as a tab strip. New isMainStripPane (any placement:'main' tenant — sessions, pages, previews) drives ⌘W and ⌃Tab; isSessionStripPane keeps gating what it should: where a session may dock (⌘T's anchor, the strip's +). * fix(desktop): preview tab selection follows the tree, not just the reverse openPreview drove tree reveals, but clicking a preview TAB only activated its pane in the tree — $rightRailActiveTabId kept naming the previous tab, so $previewTarget (⌘L quote labels, the titlebar's has-preview state) reported a tab that wasn't on screen. The mirror now also listens tree→store: when the interacted zone's active pane is a preview tile, the store selection follows. Both directions converge on the same id, so no ping-pong. * fix(desktop): session drags land in preview and page zones tileZoneHost replaces chatZonePane: a zone hosting any main tile (a Browser tile, a page) accepts stack and split drops — the known asymmetry where you could drag a preview tab out but never drag a session in. Only a CHAT zone's center is the link-to-composer drop; a preview zone's center stacks, since there's no composer to link to. * chore(desktop): drop the rail's dead multi-close verbs closeActiveRightRailTab / closeOtherRightRailTabs / closeRightRailTabsToRight lost their last callers when ⌘W and the close menu moved to the zone strip's shared rungs; the tests now exercise closeRightRailTab's own fallback behavior directly. * fix(desktop): open_preview lands whenever its session is on screen The preview.open handler honored the event only when its session was the FOCUSED one — but the turn that runs open_preview is usually a tile's session, and by the time the tool fires the user's last click has often parked focus on main (or anywhere else). The tool reported success, the store never wrote, and nothing appeared: an explicit 'open reddit' silently vanished. On-screen is the right bar: honor the open when the session is the primary chat or any open tile, which keeps truly invisible background sessions from yanking the pane (offer, don't hijack) without eating opens the user asked for. * fix(desktop): one Browser — a second URL navigates it, not a second tab Tabs were keyed url:
, so every distinct page the agent opened stacked another BROWSER tab — three opens, three Browsers, each titled identically because the tab deliberately names the surface, not the page. The title already said singleton; the key disagreed. URL targets now share one url:browser id: openPreview re-fronts the tab and swaps its target, and the pane rebuilds its webview against the new address. Files and artifacts keep per-identity tabs. Restored storage rekeys old per-address rows and keeps only the most recent. --- apps/desktop/src/app/chat/close-tab.test.ts | 26 +- apps/desktop/src/app/chat/close-tab.ts | 20 +- .../composer/status-stack/preview-row.tsx | 6 +- apps/desktop/src/app/chat/pane-mirror.ts | 10 + apps/desktop/src/app/chat/preview-tile.tsx | 143 +++++++ apps/desktop/src/app/chat/right-rail/index.ts | 2 +- .../app/chat/right-rail/preview-console.tsx | 12 - .../app/chat/right-rail/preview-pane.test.tsx | 28 +- .../src/app/chat/right-rail/preview-pane.tsx | 73 ++-- .../chat/right-rail/preview-strip-tools.tsx | 97 +++++ .../src/app/chat/right-rail/preview.tsx | 179 ++------- apps/desktop/src/app/chat/session-drag.ts | 27 +- apps/desktop/src/app/contrib/controller.tsx | 87 +---- apps/desktop/src/app/contrib/panes.tsx | 37 +- .../app/session/hooks/preview-open.test.tsx | 25 ++ .../app/session/hooks/use-preview-routing.ts | 26 +- .../tree/focused-session-tab.test.ts | 41 ++ .../tree/renderer/lone-header.test.ts | 15 +- .../pane-shell/tree/renderer/lone-header.ts | 11 +- .../pane-shell/tree/renderer/track-model.ts | 7 + .../pane-shell/tree/renderer/tree-group.tsx | 360 +++++++++--------- .../src/components/pane-shell/tree/store.ts | 60 ++- apps/desktop/src/components/ui/pane-tab.tsx | 150 ++++++++ apps/desktop/src/i18n/ar.ts | 4 - apps/desktop/src/i18n/en.ts | 4 - apps/desktop/src/i18n/ja.ts | 4 - apps/desktop/src/i18n/types.ts | 4 - apps/desktop/src/i18n/zh-hant.ts | 4 - apps/desktop/src/i18n/zh.ts | 4 - apps/desktop/src/store/artifacts.test.ts | 4 +- apps/desktop/src/store/layout.ts | 2 - apps/desktop/src/store/preview.test.ts | 31 +- apps/desktop/src/store/preview.ts | 79 ++-- 33 files changed, 899 insertions(+), 683 deletions(-) create mode 100644 apps/desktop/src/app/chat/preview-tile.tsx create mode 100644 apps/desktop/src/app/chat/right-rail/preview-strip-tools.tsx 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() {