diff --git a/apps/desktop/src/app/chat/close-tab.test.ts b/apps/desktop/src/app/chat/close-tab.test.ts index 3dba05d127..093aaba612 100644 --- a/apps/desktop/src/app/chat/close-tab.test.ts +++ b/apps/desktop/src/app/chat/close-tab.test.ts @@ -25,11 +25,7 @@ vi.mock('@/components/pane-shell/tree/store', () => ({ vi.mock('@/store/session-states', () => ({ closeSessionTile: (...args: unknown[]) => closeSessionTile(...args), - nextSessionTileForWorkspace: () => nextSessionTileForWorkspace(), - // preview.ts reads the focused session when stamping/deriving tabs; this - // suite drives the writable $selectedStoredSessionId instead, so a static - // null stub is enough. - $focusedStoredSessionId: atom(null) + nextSessionTileForWorkspace: () => nextSessionTileForWorkspace() })) vi.mock('@/store/profile', () => ({ diff --git a/apps/desktop/src/app/chat/preview-tile.test.ts b/apps/desktop/src/app/chat/preview-tile.test.ts index b5fe50d494..eac79de3bb 100644 --- a/apps/desktop/src/app/chat/preview-tile.test.ts +++ b/apps/desktop/src/app/chat/preview-tile.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' vi.mock('./right-rail/preview', () => ({ PreviewTilePane: () => null @@ -146,90 +146,3 @@ describe('preview tiles stack, not split (#93610)', () => { expect(dockOf('preview-tile:file:/tmp/c.ts')?.pos).toBe('right') }) }) - -// --------------------------------------------------------------------------- -// Session-scoped rail (#73890): only the FOCUSED session's tabs (plus pins) -// become panes, so switching sessions swaps the drawer, and pinning a tab -// surfaces it in every session. -// --------------------------------------------------------------------------- - -describe('preview tiles mirror the visible session tabs', () => { - beforeEach(() => { - window.localStorage.clear() - vi.resetModules() - }) - - afterEach(() => { - vi.resetModules() - }) - - async function setup() { - const preview = await import('@/store/preview') - const session = await import('@/store/session') - const tree = await import('@/components/pane-shell/tree/store') - const model = await import('@/components/pane-shell/tree/model') - const { registry } = await import('@/contrib/registry') - const { watchPreviewTiles } = await import('./preview-tile') - - registry.register({ - area: 'panes', - data: { placement: 'main', uncloseable: true }, - id: 'workspace', - render: () => null, - title: 'workspace' - }) - tree.declareDefaultTree(model.group(['workspace'], { active: 'workspace', id: 'grp-main' })) - // The app root wires registry changes into the tree; mirror it here. - tree.watchContributedPanes() - watchPreviewTiles() - - return { model, preview, session, tree } - } - - const htmlTarget = (path: string) => - ({ - kind: 'file', - label: path.split('/').at(-1) ?? path, - path, - previewKind: 'html', - source: path, - url: `file://${path}` - }) as const - - it('renders only the focused session previews, pinning spans sessions', async () => { - const { preview, session, tree } = await setup() - - session.$selectedStoredSessionId.set('sess-1') - preview.openPreview(htmlTarget('/work/a.html')) - - expect(tree.treePanesWithPrefix('preview-tile:')).toHaveLength(1) - - // Switching sessions hides the pane (the tab stays open in the store). - session.$selectedStoredSessionId.set('sess-2') - expect(tree.treePanesWithPrefix('preview-tile:')).toHaveLength(0) - expect(preview.$previewTabs.get()).toHaveLength(1) - - // Pinning makes it visible again in the new session. - preview.setPreviewTabPinned(preview.$previewTabs.get()[0]!.id, true) - expect(tree.treePanesWithPrefix('preview-tile:')).toHaveLength(1) - - // And closing the tab removes the pane for good. - preview.closeRightRailTab(preview.$previewTabs.get()[0]!.id) - expect(tree.treePanesWithPrefix('preview-tile:')).toHaveLength(0) - }) - - it('does not create panes for another session tabs', async () => { - const { preview, session, tree } = await setup() - - session.$selectedStoredSessionId.set('sess-1') - preview.openPreview(htmlTarget('/work/a.html')) - - session.$selectedStoredSessionId.set('sess-2') - preview.openPreview(htmlTarget('/work/b.html')) - - expect(tree.treePanesWithPrefix('preview-tile:')).toHaveLength(1) - - session.$selectedStoredSessionId.set('sess-1') - expect(tree.treePanesWithPrefix('preview-tile:')).toHaveLength(1) - }) -}) diff --git a/apps/desktop/src/app/chat/preview-tile.tsx b/apps/desktop/src/app/chat/preview-tile.tsx index b9776adb05..534ac881f1 100644 --- a/apps/desktop/src/app/chat/preview-tile.tsx +++ b/apps/desktop/src/app/chat/preview-tile.tsx @@ -28,9 +28,8 @@ import { openExternalLink } from '@/lib/external-link' import { $rightRailActiveTabId, type RightRailTabId, selectRightRailTab } from '@/store/layout' import { $browserPages, - $dockedVisiblePreviewTabs, + $dockedPreviewTabs, $previewTabs, - $visiblePreviewTabs, adoptPersistedBrowserTab, type BrowserPage, closeRightRailTab, @@ -38,8 +37,7 @@ import { markBrowserTabPopped, newBrowserTab, popOutBrowserTab, - type PreviewTarget, - setPreviewTabPinned + type PreviewTarget } from '@/store/preview' import { explicitOpenBlocksZone, PREVIEW_TILE_PREFIX } from '@/store/preview-explicit' import { canOpenBrowserWindow } from '@/store/windows' @@ -98,26 +96,6 @@ function browserTabMenuPrefix(tabId: string) { ) } -/** Pin/unpin for EVERY preview tab's zone menu: pinned tabs are the explicit - * cross-session workspace, everything else belongs to the session that - * opened it (#73890). URL tabs keep their browser rows below it. */ -function previewTabMenuPrefix(tabId: string) { - const pinned = Boolean($previewTabs.get().find(tab => tab.id === tabId)?.pinned) - const browserRows = browserTabMenuPrefix(tabId) - - return (kit: MenuKit) => ( - <> - {renderActionItem(kit, { - icon: pinned ? 'pinned' : 'pin', - key: 'pin', - label: translateNow(pinned ? 'preview.unpin' : 'preview.pin'), - onSelect: () => setPreviewTabPinned(tabId, !pinned) - })} - {browserRows?.(kit) ?? null} - - ) -} - /** Tab title. A URL tab is titled by the CONTRIBUTION as the surface — see * `BrowserTabLabel` for the live page name the strip actually renders — so * navigating doesn't re-register the pane on every hop. A file names the @@ -212,7 +190,7 @@ function existingPreviewAnchor(tabId: string): string | undefined { return inTree } - const other = $dockedVisiblePreviewTabs.get().find(tab => tab.id !== tabId) + const other = $dockedPreviewTabs.get().find(tab => tab.id !== tabId) return other ? previewPaneId(other.id) : undefined } @@ -242,23 +220,7 @@ export function watchPreviewTiles(): void { } $rightRailActiveTabId.listen(reveal) - // Visible-list changes: a re-open of the already-active tab refreshes the - // target through the visible list too, while tab mutations belonging to a - // BACKGROUND session must not try to reveal panes that are not mounted. - $visiblePreviewTabs.listen(reveal) - - // A session switch (or an unpin) can leave the active tab outside the - // visible set — re-home the selection to the first visible tab so the strip - // and the pane never point at a hidden preview. - const rehome = () => { - const visible = $visiblePreviewTabs.get() - - if (!visible.some(tab => tab.id === $rightRailActiveTabId.get())) { - selectRightRailTab(visible[0]?.id ?? null) - } - } - - $visiblePreviewTabs.listen(rehome) + $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 @@ -299,12 +261,11 @@ export function watchPreviewTiles(): void { } const watchPreviewTileMirror = paneMirror<{ id: string }>({ - // Only the FOCUSED session's tabs (plus pins) become panes — switching - // sessions swaps the drawer; a hidden tab's pane leaves the tree without - // closing the tab (paneMirror's sync disposes, it doesn't call close). - // Composed with $dockedPreviewTabs so a popped-out Browser pane also - // leaves the docked tree. - source: $dockedVisiblePreviewTabs, + source: $dockedPreviewTabs, + // Unscoped on purpose. `$previewTabs` is one global Browser/file surface — + // clicking a link in a bot chat must open the same pane Sessions already + // shows. Scoping this to `sessions` filtered the pane out of Bot Mode, so + // `openPreview` ran and the click looked like a no-op. key: tab => tab.id, prefix: PREVIEW_TILE_PREFIX, // The FIRST preview still opens its own zone docked beside main (identical @@ -321,10 +282,7 @@ const watchPreviewTileMirror = paneMirror<{ id: string }>({ // A Browser is a vessel, so there can be more of it — a file peek is one of // a kind and leaves the strip's "+" to whatever else the zone holds. newTab: tabId => (targetFor(tabId)?.kind === 'url' ? newBrowserTab : undefined), - // Pin/unpin rides the zone tab menu for every preview tab: pinned tabs are - // the explicit cross-session workspace, everything else belongs to the - // session that opened it (#73890). - tabMenuPrefix: previewTabMenuPrefix, + tabMenuPrefix: browserTabMenuPrefix, lifecycleKeepAlive: tabId => { const target = targetFor(tabId) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 2ec6f6a6f6..8faabcd810 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -47,7 +47,6 @@ import { import { $gatewaySwitching } from '@/store/gateway-switch' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' -import { prunePreviewTabsForSession } from '@/store/preview' import { $activeGatewayProfile, $gatewaySwapTarget, @@ -3298,14 +3297,6 @@ export function useSessionActions({ // the closing runtime id — the journal keys on the stored id. purgeInFlightTurnJournals([...removedIds, closingRuntimeId]) - // Preview tabs are session-owned: drop them with the session (pinned - // tabs survive — they belong to the workspace, not the session). - for (const id of removedIds) { - if (id) { - prunePreviewTabsForSession(id) - } - } - if (closingRuntimeId) { clearQueuedPrompts(closingRuntimeId) clearSessionControl(closingRuntimeId) diff --git a/apps/desktop/src/i18n/ar_artifacts.ts b/apps/desktop/src/i18n/ar_artifacts.ts index 9876dc7c0e..5e38757d0c 100644 --- a/apps/desktop/src/i18n/ar_artifacts.ts +++ b/apps/desktop/src/i18n/ar_artifacts.ts @@ -55,8 +55,6 @@ export const arArtifacts = { }, preview: { tab: 'معاينة', - pin: 'تثبيت في مساحة العمل', - unpin: 'إلغاء التثبيت من مساحة العمل', closePane: 'إغلاق جزء المعاينة', loading: 'جار تحميل المعاينة', unavailable: 'المعاينة غير متاحة', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 5931026a62..3d1e944454 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -4862,8 +4862,6 @@ export const en: Translations = { preview: { tab: 'Preview', - pin: 'Pin to workspace', - unpin: 'Unpin from workspace', 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 3b0687ccbf..b79c56c868 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -3585,8 +3585,6 @@ export const ja = defineLocale({ preview: { tab: 'プレビュー', - pin: 'ワークスペースにピン留め', - unpin: 'ワークスペースからピン留めを外す', closePane: 'プレビューペインを閉じる', loading: 'プレビューを読み込み中', unavailable: 'プレビューは利用できません', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 76d1efcede..2629c6484c 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -4110,8 +4110,6 @@ export interface Translations { preview: { tab: string - pin: string - unpin: string closePane: string loading: string unavailable: string diff --git a/apps/desktop/src/i18n/zh-hant_artifacts.ts b/apps/desktop/src/i18n/zh-hant_artifacts.ts index a8928fd880..589fe485d9 100644 --- a/apps/desktop/src/i18n/zh-hant_artifacts.ts +++ b/apps/desktop/src/i18n/zh-hant_artifacts.ts @@ -58,8 +58,6 @@ export const zhHantArtifacts = { preview: { tab: '預覽', - pin: '固定到工作區', - unpin: '從工作區取消固定', closePane: '關閉預覽窗格', loading: '正在載入預覽', unavailable: '預覽不可用', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 355fa64af2..2bb89c04c2 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -4557,8 +4557,6 @@ export const zh = defineLocale({ preview: { tab: '预览', - pin: '固定到工作区', - unpin: '从工作区取消固定', closePane: '关闭预览面板', loading: '正在加载预览', unavailable: '预览不可用', diff --git a/apps/desktop/src/store/preview.test.ts b/apps/desktop/src/store/preview.test.ts index 09605586f1..6cfa1a9988 100644 --- a/apps/desktop/src/store/preview.test.ts +++ b/apps/desktop/src/store/preview.test.ts @@ -7,7 +7,6 @@ import { $previewServerRestartStatus, $previewTabs, $previewTarget, - $visiblePreviewTabs, beginPreviewServerRestart, closeBrowserPreviewMatchingLiveUrl, closePreviewForSource, @@ -23,12 +22,9 @@ import { previewTabId, type PreviewTarget, progressPreviewServerRestart, - prunePreviewTabsForSession, renderedHtmlTarget, - setPreviewRenderMode, - setPreviewTabPinned + setPreviewRenderMode } from './preview' -import { $selectedStoredSessionId } from './session' function fileTarget(source: string): PreviewTarget { return { kind: 'file', label: source, path: source, previewKind: 'html', source, url: `file://${source}` } @@ -46,7 +42,6 @@ describe('preview store', () => { beforeEach(() => { $browserPages.set({}) $previewServerRestart.set(null) - $selectedStoredSessionId.set(null) closeRightRail() window.localStorage.clear() }) @@ -54,7 +49,6 @@ describe('preview store', () => { afterEach(() => { $browserPages.set({}) $previewServerRestart.set(null) - $selectedStoredSessionId.set(null) closeRightRail() window.localStorage.clear() }) @@ -74,7 +68,7 @@ describe('preview store', () => { it('opens the pane and fronts the new tab', () => { openPreview(fileTarget('/work/demo.html')) - expect($rightRailActiveTabId.get()).toBe('file:/work/demo.html') + expect($rightRailActiveTabId.get()).toBe('file:file:///work/demo.html') expect($previewTarget.get()?.path).toBe('/work/demo.html') }) @@ -393,208 +387,3 @@ describe('preview store', () => { expect(restored.map(tab => tab.target.path)).toEqual(['/work/alive.html']) }) }) - -describe('preview session scoping', () => { - afterEach(() => { - $selectedStoredSessionId.set(null) - closeRightRail() - window.localStorage.clear() - }) - - it('stamps the active session on tabs it opens', () => { - $selectedStoredSessionId.set('sess-1') - openPreview(fileTarget('/work/demo.html')) - - expect($previewTabs.get()[0]?.sessionId).toBe('sess-1') - }) - - it('shows only the active session tabs plus pinned ones', () => { - $selectedStoredSessionId.set('sess-1') - openPreview(fileTarget('/work/a.html')) - $selectedStoredSessionId.set('sess-2') - openPreview(fileTarget('/work/b.html')) - - expect($visiblePreviewTabs.get().map(tab => tab.target.path)).toEqual(['/work/b.html']) - - // Pinning makes the first session's tab visible in every session. - const aTab = $previewTabs.get().find(tab => tab.target.path === '/work/a.html') - setPreviewTabPinned(aTab!.id, true) - - expect( - $visiblePreviewTabs - .get() - .map(tab => tab.target.path) - .sort() - ).toEqual(['/work/a.html', '/work/b.html']) - - // Unpinning hides it from the other session again. - setPreviewTabPinned(aTab!.id, false) - expect($visiblePreviewTabs.get().map(tab => tab.target.path)).toEqual(['/work/b.html']) - }) - - it('prunes a deleted session tabs but keeps its pins', () => { - $selectedStoredSessionId.set('sess-1') - openPreview(fileTarget('/work/a.html')) - setPreviewTabPinned($previewTabs.get()[0]!.id, true) - $selectedStoredSessionId.set('sess-2') - openPreview(fileTarget('/work/b.html')) - - prunePreviewTabsForSession('sess-2') - expect($previewTabs.get().map(tab => tab.target.path)).toEqual(['/work/a.html']) - - // Pinned tabs belong to the workspace, not the session that opened them. - prunePreviewTabsForSession('sess-1') - expect($previewTabs.get().map(tab => tab.target.path)).toEqual(['/work/a.html']) - }) - - it('adopts ownerless draft tabs when a session appears', () => { - openPreview(fileTarget('/work/draft.html')) - expect($previewTabs.get()[0]?.sessionId).toBeUndefined() - - $selectedStoredSessionId.set('sess-new') - expect($previewTabs.get()[0]?.sessionId).toBe('sess-new') - // The id is rekeyed onto the session-scoped form so a later open of the - // same file dedupes instead of stacking. - expect($previewTabs.get()[0]?.id).toBe('file:sess-new:/work/draft.html') - expect($visiblePreviewTabs.get().map(tab => tab.target.path)).toEqual(['/work/draft.html']) - }) - - it('canonicalizes file identities across entry points', () => { - expect(previewTabId(fileTarget('/work/demo.html'))).toBe('file:/work/demo.html') - - const viaPlainPath = previewTabId({ ...fileTarget('/work/demo.html'), url: '/work/demo.html' }) - expect(viaPlainPath).toBe('file:/work/demo.html') - }) - - it('scopes file tab ids to the session, so two sessions can open the same file', () => { - expect(previewTabId(fileTarget('/work/demo.html'), 'sess-1')).toBe('file:sess-1:/work/demo.html') - expect(previewTabId(fileTarget('/work/demo.html'), 'sess-2')).toBe('file:sess-2:/work/demo.html') - - $selectedStoredSessionId.set('sess-1') - openPreview(fileTarget('/work/demo.html')) - $selectedStoredSessionId.set('sess-2') - openPreview(fileTarget('/work/demo.html')) - - const tabs = $previewTabs.get() - - expect(tabs).toHaveLength(2) - expect(tabs[0]).toMatchObject({ sessionId: 'sess-1', id: 'file:sess-1:/work/demo.html' }) - expect(tabs[1]).toMatchObject({ sessionId: 'sess-2', id: 'file:sess-2:/work/demo.html' }) - expect($visiblePreviewTabs.get()).toHaveLength(1) - }) - - it('re-opening the same file in the same session keeps the tab owner and pin', () => { - $selectedStoredSessionId.set('sess-1') - openPreview(fileTarget('/work/demo.html')) - setPreviewTabPinned($previewTabs.get()[0]!.id, true) - - openPreview({ ...fileTarget('/work/demo.html'), label: 'refreshed' }) - - expect($previewTabs.get()).toHaveLength(1) - expect($previewTabs.get()[0]).toMatchObject({ sessionId: 'sess-1', pinned: true }) - expect($previewTabs.get()[0]?.target.label).toBe('refreshed') - }) - - it('unpinning an ownerless tab adopts the current session', () => { - openPreview(fileTarget('/work/legacy.html')) - setPreviewTabPinned($previewTabs.get()[0]!.id, true) - - $selectedStoredSessionId.set('sess-1') - setPreviewTabPinned($previewTabs.get()[0]!.id, false) - - expect($previewTabs.get()[0]).toMatchObject({ pinned: false, sessionId: 'sess-1' }) - expect($previewTabs.get()[0]?.id).toBe('file:sess-1:/work/legacy.html') - }) - - it('closes by source only within the current session', () => { - $selectedStoredSessionId.set('sess-1') - openPreview(fileTarget('/work/a.html')) - $selectedStoredSessionId.set('sess-2') - openPreview(fileTarget('/work/a.html')) - - // The hidden session's tab must not be closed by the other session's row. - expect(closePreviewForSource('/work/a.html')).toBe(true) - expect($previewTabs.get()).toHaveLength(1) - expect($previewTabs.get()[0]?.sessionId).toBe('sess-1') - }) - - it('reopens a pinned legacy row instead of stacking a second tab', () => { - // A migrated legacy row: pinned, unprefixed id, no owner. - $previewTabs.set([{ id: 'file:/work/a.html', target: fileTarget('/work/a.html'), pinned: true }]) - - $selectedStoredSessionId.set('sess-1') - openPreview(fileTarget('/work/a.html')) - - expect($previewTabs.get()).toHaveLength(1) - expect($previewTabs.get()[0]).toMatchObject({ pinned: true, sessionId: 'sess-1' }) - expect($previewTabs.get()[0]?.id).toBe('file:sess-1:/work/a.html') - expect($visiblePreviewTabs.get()).toHaveLength(1) - }) - - it('adoption dedupes against an already session-scoped row', () => { - // Both rows for the same file coexist before the session appears. - $previewTabs.set([ - { id: 'file:/work/a.html', target: fileTarget('/work/a.html') }, - { id: 'file:sess-1:/work/a.html', target: fileTarget('/work/a.html'), sessionId: 'sess-1' } - ]) - - $selectedStoredSessionId.set('sess-1') - - expect($previewTabs.get()).toHaveLength(1) - expect($previewTabs.get()[0]?.id).toBe('file:sess-1:/work/a.html') - }) - - it('adoption dedupe keeps the active selection valid', () => { - // The colliding row is the ACTIVE tab when adoption rekeys the draft - // onto the same id — the survivor carries that id, so the selection must - // not dangle. - $previewTabs.set([ - { id: 'file:/work/a.html', target: fileTarget('/work/a.html') }, - { id: 'file:sess-1:/work/a.html', target: fileTarget('/work/a.html'), sessionId: 'sess-1' } - ]) - selectRightRailTab('file:sess-1:/work/a.html') - - $selectedStoredSessionId.set('sess-1') - - expect($previewTabs.get()).toHaveLength(1) - expect($rightRailActiveTabId.get()).toBe('file:sess-1:/work/a.html') - }) - - it('migrates legacy unscoped tabs to pinned and rekeys their ids', () => { - const raw = JSON.stringify([ - { id: 'file:file:///work/a.html', target: fileTarget('/work/a.html') }, - { id: 'file:file:///work/b.html', target: fileTarget('/work/b.html'), sessionId: 'sess-9' } - ]) - - const decoded = decodePreviewTabs(raw) - - expect(decoded).toHaveLength(2) - expect(decoded[0]).toMatchObject({ id: 'file:/work/a.html', pinned: true }) - expect(decoded[1]?.id).toBe('file:sess-9:/work/b.html') - expect(decoded[1]?.sessionId).toBe('sess-9') - expect(decoded[1]?.pinned).toBeUndefined() - }) - - it('does not re-pin a persisted unpinned row', () => { - const raw = JSON.stringify([ - { id: 'file:/work/a.html', target: fileTarget('/work/a.html'), sessionId: 'sess-1', pinned: false } - ]) - - const decoded = decodePreviewTabs(raw) - - expect(decoded[0]?.pinned).toBe(false) - }) - - it('dedupes the same file persisted under both old and canonical ids', () => { - const raw = JSON.stringify([ - { id: 'file:file:///work/a.html', target: fileTarget('/work/a.html') }, - { id: 'file:/work/a.html', target: { ...fileTarget('/work/a.html'), label: 'updated' } } - ]) - - const decoded = decodePreviewTabs(raw) - - expect(decoded).toHaveLength(1) - expect(decoded[0]?.id).toBe('file:/work/a.html') - expect(decoded[0]?.target.label).toBe('updated') - }) -}) diff --git a/apps/desktop/src/store/preview.ts b/apps/desktop/src/store/preview.ts index 8c83db786f..2c7af63608 100644 --- a/apps/desktop/src/store/preview.ts +++ b/apps/desktop/src/store/preview.ts @@ -13,7 +13,6 @@ import { recordFeatureUse } from './desktop-metrics' import { $rightRailActiveTabId, type RightRailTabId, selectRightRailTab } from './layout' import { clearExplicitPreviewOpen, noteExplicitPreviewOpen, PREVIEW_TILE_PREFIX } from './preview-explicit' import { normalizeProfileKey } from './profile' -import { $focusedStoredSessionId } from './session-focus' import { canOpenBrowserWindow, openBrowserInNewWindow } from './windows' /** @@ -74,12 +73,6 @@ export interface PreviewServerRestart { export interface PreviewTab { id: RightRailTabId target: PreviewTarget - /** The session that opened the tab. Absent only on legacy rows written - * before session scoping (decodePreviewTabs migrates those to pinned). */ - sessionId?: string - /** Pinned tabs render in EVERY session — the explicit cross-session - * workspace. Everything else is visible only in the session that opened it. */ - pinned?: boolean } const TABS_STORAGE_KEY = 'hermes.desktop.previewTabs.v2' @@ -142,44 +135,17 @@ export function decodePreviewTabs(raw: string): PreviewTab[] { } function parseTabList(parsed: unknown): PreviewTab[] { - const pdfUpgraded = (Array.isArray(parsed) ? parsed.filter(isPreviewTab) : []) - .map(tab => - isPdfFileTarget(tab.target) && tab.target.previewKind === 'binary' - ? { ...tab, target: { ...tab.target, previewKind: 'pdf' as const } } - : tab - ) - // Drop tombstoned file tabs (a previous session confirmed the file is - // gone). Keeping them would re-probe a known-dead path on every boot. - .filter(tab => !tab.target.missing) - - // Legacy rows (written before session scoping) have no owner and no way to - // recover one — keep them as workspace-pinned rather than dropping them or - // dumping every stale tab into one chat. Explicit `!== undefined` checks: - // a persisted `pinned: false` must not be re-pinned. - const owned = pdfUpgraded.map(tab => - tab.sessionId !== undefined || tab.pinned !== undefined ? tab : { ...tab, pinned: true } + return ( + (Array.isArray(parsed) ? parsed.filter(isPreviewTab) : []) + .map(tab => + isPdfFileTarget(tab.target) && tab.target.previewKind === 'binary' + ? { ...tab, target: { ...tab.target, previewKind: 'pdf' as const } } + : tab + ) + // Drop tombstoned file tabs (a previous session confirmed the file is + // gone). Keeping them would re-probe a known-dead path on every boot. + .filter(tab => !tab.target.missing) ) - - // One Browser: rekey restored URL tabs onto the singleton id (rows written - // before the id existed carried one id per address). File tabs rekey onto - // their session-scoped canonical id. Keep only the LAST row per id — the - // most recently opened wins. - const lastUrl = owned.findLast(tab => tab.target.kind === 'url') - const deduped = new Map() - - for (const tab of owned) { - if (tab.target.kind === 'url' && tab !== lastUrl) { - continue - } - - const id = - tab.target.kind === 'url' || tab.target.kind === 'file' - ? previewTabId(tab.target, tab.sessionId) - : tab.id - deduped.set(id, { ...tab, id }) - } - - return [...deduped.values()] } /** The tabs a profile's rail is showing, keyed by profile. */ @@ -374,63 +340,6 @@ if (typeof window !== 'undefined') { } } -/** Tabs the ACTIVE session sees: its own tabs plus everything pinned. The - * layout-tree mirror renders only these, so a session switch swaps the drawer - * and pinned tabs are the explicit cross-session workspace. While no session - * exists (a fresh draft), ownerless tabs stay visible. */ -export const $visiblePreviewTabs = computed( - [$previewTabs, $focusedStoredSessionId], - (tabs, sessionId) => - tabs.filter( - tab => tab.pinned || tab.sessionId === sessionId || (sessionId == null && tab.sessionId == null) - ) -) - -// A fresh draft has no session yet, so tabs opened there are ownerless — adopt -// them into the session the moment one exists (rekeying file ids onto the -// session-scoped form, so a later open of the same file dedupes), or the -// drawer would silently lose them at first send. -$focusedStoredSessionId.listen(sessionId => { - if (sessionId == null) { - return - } - - const tabs = $previewTabs.get() - - if (tabs.some(tab => tab.sessionId == null && !tab.pinned)) { - const activeId = $rightRailActiveTabId.get() - let newActiveId = activeId - const updated = tabs.map(tab => { - if (tab.sessionId != null || tab.pinned) { - return tab - } - - const nextId = tab.target.kind === 'file' ? previewTabId(tab.target, sessionId) : tab.id - - if (tab.id === activeId) { - newActiveId = nextId - } - - return { ...tab, id: nextId, sessionId } - }) - - // A rekey can collide with an already session-scoped row of the same file - // (defensive: adoption runs synchronously with the focus change, so a real - // open can't interleave — but keep the no-duplicate invariant explicit). - const deduped = new Map() - - for (const tab of updated) { - deduped.set(tab.id, tab) - } - - $previewTabs.set([...deduped.values()]) - - if (newActiveId !== activeId) { - selectRightRailTab(newActiveId) - } - } -}) - /** The tab the rail actually shows. A stale or missing selection falls back to * the first tab, so the strip, `⌘W`, and the pane never disagree about which * tab is on screen. */ @@ -439,7 +348,7 @@ function resolveActiveTab(tabs: PreviewTab[], activeTabId: RightRailTabId | null } function activePreviewTab(): PreviewTab | null { - return resolveActiveTab($visiblePreviewTabs.get(), $rightRailActiveTabId.get()) + return resolveActiveTab($previewTabs.get(), $rightRailActiveTabId.get()) } // A restored active id whose tab didn't survive validation would leave the rail @@ -448,13 +357,13 @@ selectRightRailTab(activePreviewTab()?.id ?? null) /** The target the rail is currently showing, or null when it has no tabs. */ export const $previewTarget = computed( - [$visiblePreviewTabs, $rightRailActiveTabId], + [$previewTabs, $rightRailActiveTabId], (tabs, activeTabId) => resolveActiveTab(tabs, activeTabId)?.target ?? null ) -/** Raw `source` strings of every tab the active session sees, for the composer - * rows that toggle a preview open and closed by the target they were handed. */ -export const $previewTabSources = computed($visiblePreviewTabs, tabs => tabs.map(tab => tab.target.source)) +/** Raw `source` strings of every open tab, for the composer rows that toggle a + * preview open and closed by the target they were handed. */ +export const $previewTabSources = computed($previewTabs, tabs => tabs.map(tab => tab.target.source)) export interface BrowserPage { title: string @@ -643,13 +552,6 @@ export const $dockedPreviewTabs = computed([$previewTabs, $poppedBrowserTabIds], popped.size === 0 ? tabs : tabs.filter(tab => !popped.has(tab.id)) ) -/** The FOCUSED session's tabs that still belong in the docked layout tree — - * the layout-tree mirror renders only these (#73890): switching sessions - * swaps the drawer, and a popped-out Browser pane stays out. */ -export const $dockedVisiblePreviewTabs = computed([$visiblePreviewTabs, $poppedBrowserTabIds], (tabs, popped) => - popped.size === 0 ? tabs : tabs.filter(tab => !popped.has(tab.id)) -) - export const $previewReloadRequest = atom(0) export const $previewServerRestart = atom(null) export const $previewServerRestartStatus = computed($previewServerRestart, restart => restart?.status ?? 'idle') @@ -657,22 +559,8 @@ export const $previewServerRestartStatus = computed($previewServerRestart, resta /** The tab that owns `target`. Files and artifacts are keyed by IDENTITY — * the same file is always the same tab, reopening it re-fronts the one it * already has. A URL has no identity here: a Browser tab is a vessel you - * navigate, so it is picked (`browserTabId`) rather than derived. - * - * A FILE tab's identity additionally carries the session that opened it — - * the same file opened in two conversations is two tabs, each owned by its - * session (the session-scoped rail, #73890). Legacy rows (no session) keep - * the unprefixed key. The `file://` scheme is stripped so the two entry - * points (a `file://` URL vs a plain path) resolve to the same key. */ -function canonicalFileKey(target: PreviewTarget): string { - return (target.path || target.url).replace(/^file:\/\//, '') -} - -export function previewTabId(target: PreviewTarget, sessionId?: null | string): RightRailTabId { - if (target.kind === 'file') { - return `file:${sessionId ? `${sessionId}:` : ''}${canonicalFileKey(target)}` - } - + * navigate, so it is picked (`browserTabId`) rather than derived. */ +export function previewTabId(target: PreviewTarget): RightRailTabId { return `${target.kind}:${target.url}` } @@ -736,36 +624,14 @@ export function setPreviewRenderMode(tabId: string, renderMode: PreviewRenderMod /** 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. - * - * The tab belongs to the session that was active when it opened (a re-open - * of an existing tab keeps the tab's owner and pin state — only the target - * refreshes). A PINNED row for the same file carries the unprefixed - * (legacy) id, so a session-scoped open would otherwise stack a second tab - * for the same file: reuse the pinned row instead — refreshed, still - * pinned, fronted. Never steal another session's owned tab: that - * coexistence is the point. */ + * only way anything reaches a preview. */ export function openPreview(target: PreviewTarget) { const current = $previewTabs.get() - const sessionId = $focusedStoredSessionId.get() ?? undefined - const id = target.kind === 'url' ? browserTabId(current) : previewTabId(target, sessionId) + const id = target.kind === 'url' ? browserTabId(current) : previewTabId(target) const index = current.findIndex(tab => tab.id === id) - const existing = - index === -1 && target.kind === 'file' - ? current.find( - tab => tab.pinned && tab.target.kind === 'file' && canonicalFileKey(tab.target) === canonicalFileKey(target) - ) - : current[index] - const tab: PreviewTab = { - id, - target: withRenderMode(target, existing?.target), - sessionId: existing?.sessionId ?? sessionId, - pinned: existing?.pinned - } + const tab: PreviewTab = { id, target: withRenderMode(target, current[index]?.target) } - const replaceIndex = existing ? current.indexOf(existing) : -1 - - $previewTabs.set(replaceIndex === -1 ? [...current, tab] : current.map((item, i) => (i === replaceIndex ? tab : item))) + $previewTabs.set(index === -1 ? [...current, tab] : current.map((item, i) => (i === index ? tab : item))) noteExplicitPreviewOpen(id) selectRightRailTab(id) } @@ -827,43 +693,6 @@ export function newBrowserTab() { selectRightRailTab(id) } -/** Pin or unpin a preview tab. Pinned tabs render in EVERY session — the - * explicit cross-session workspace; unpinning returns it to its session - * (adopting the current one, and rekeying the file id, when the tab never - * had an owner). */ -export function setPreviewTabPinned(tabId: string, pinned: boolean): void { - const currentSession = $focusedStoredSessionId.get() ?? undefined - const activeId = $rightRailActiveTabId.get() - let newActiveId = activeId - - $previewTabs.set( - $previewTabs.get().map(tab => { - if (tab.id !== tabId) { - return tab - } - - const nextSession = tab.sessionId ?? (!pinned ? currentSession : undefined) - const nextId = tab.target.kind === 'file' ? previewTabId(tab.target, nextSession) : tab.id - - if (tab.id === activeId) { - newActiveId = nextId - } - - return { ...tab, id: nextId, pinned, sessionId: nextSession } - }) - ) - - if (newActiveId !== activeId) { - selectRightRailTab(newActiveId) - } -} - -/** Drop the tabs a deleted session opened. Pinned tabs survive — they belong - * to the workspace, not the session that opened them. */ -export function prunePreviewTabsForSession(sessionId: string): void { - $previewTabs.set($previewTabs.get().filter(tab => tab.pinned || tab.sessionId !== sessionId)) -} - export function closeRightRailTab(tabId: string) { const current = $previewTabs.get() const index = current.findIndex(tab => tab.id === tabId) @@ -894,18 +723,9 @@ export function closeRightRailTab(tabId: string) { } } -/** Close the tab showing `source` in the CURRENT session, if one is open. - * Returns whether it closed. */ +/** Close the tab showing `source`, if one is open. Returns whether it closed. */ export function closePreviewForSource(source: string): boolean { - const tab = $visiblePreviewTabs.get().find(item => item.target.source === source) - - if (!tab) { - return false - } - - closeRightRailTab(tab.id) - - return true + return closePreviewMatching(source) } /** Close the first docked Browser tab whose current page URL matches. @@ -963,14 +783,14 @@ export function closeBrowserPreviewMatchingLiveUrl(...candidates: string[]): boo return true } -function closePreviewMatchingTabs(candidates: string[]): boolean { +function closePreviewMatchingTabs(tabs: PreviewTab[], candidates: string[]): boolean { const queries = [...new Set(candidates.map(value => value.trim()).filter(Boolean))] if (queries.length === 0) { return false } - const tab = $visiblePreviewTabs.get().find(item => { + const tab = tabs.find(item => { const fields = [item.target.source, item.target.url, item.target.label] return queries.some(query => fields.includes(query)) @@ -989,13 +809,16 @@ function closePreviewMatchingTabs(candidates: string[]): boolean { * Empty candidates are a no-op so a missed match cannot wipe the rail — * closing the whole pane is `closeRightRail`. */ export function closePreviewMatching(...candidates: string[]): boolean { - return closePreviewMatchingTabs(candidates) + return closePreviewMatchingTabs($previewTabs.get(), candidates) } /** Agent-driven close is scoped to the docked rail; an independent Browser * window owns popped tabs and must not lose its backing state here. */ export function closeDockedPreviewMatching(...candidates: string[]): boolean { - return closePreviewMatchingTabs(candidates) + const popped = $poppedBrowserTabIds.get() + const docked = $previewTabs.get().filter(tab => !popped.has(tab.id)) + + return closePreviewMatchingTabs(docked, candidates) } /** Artifact tabs can't outlive the registry they read from, so clearing it diff --git a/apps/desktop/src/store/session-focus.ts b/apps/desktop/src/store/session-focus.ts index e558ad0a89..cfd797bf24 100644 --- a/apps/desktop/src/store/session-focus.ts +++ b/apps/desktop/src/store/session-focus.ts @@ -4,8 +4,6 @@ import { findGroup, findGroupOfPane } from '@/components/pane-shell/tree/model' import { $activeTreeGroup, $layoutTree } from '@/components/pane-shell/tree/store' import { $workspaceMode } from '@/components/pane-shell/workspace-scope' -import { $selectedStoredSessionId } from './session' - // A chat surface: the primary's workspace or a session tile. Everything else a // zone can show — the sessions list, Files, Terminal, a preview tab — is chrome. const isChatPane = (paneId?: string): boolean => paneId === 'workspace' || Boolean(paneId?.startsWith('session-tile:')) @@ -52,22 +50,3 @@ export const $focusedTreePaneId = computed( return active } ) - -/** The stored id of the session the user is working in: a focused - * `session-tile:` pane IS that session, anything else falls back to - * the route-driven primary selection. - * - * Lives HERE, not in session-states.ts, because low-level stores (the preview - * rail) need it and session-states imports them — defining it there made - * `session-states` ⇄ `preview` a load cycle. The inputs (the layout tree and - * the primary selection) are both leaf stores, so every consumer can share one - * derivation without dragging session-states in. */ -export const TILE_PANE_PREFIX = 'session-tile:' - -export const $focusedSessionIsTile = computed($focusedTreePaneId, active => - Boolean(active?.startsWith(TILE_PANE_PREFIX)) -) - -export const $focusedStoredSessionId = computed([$focusedTreePaneId, $selectedStoredSessionId], (active, selected) => - active?.startsWith(TILE_PANE_PREFIX) ? active.slice(TILE_PANE_PREFIX.length) : selected -) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index b76dac383b..6b66f4c77f 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -73,7 +73,7 @@ import { setTurnStartedAt } from './session' import { secondaryProfileOwnerForEvent } from './session-event-provenance' -import { $focusedSessionIsTile, $focusedStoredSessionId, TILE_PANE_PREFIX } from './session-focus' +import { $focusedTreePaneId } from './session-focus' import { assertSessionOwnerResolved } from './session-owner-resolution' import { isSessionOwnerRoute, @@ -1311,6 +1311,7 @@ export interface SessionTileWorkspaceScope { // set, with runtime bindings dropped so tiles re-resume on their own gateway. const TILES_KEY = 'hermes.desktop.sessionTiles.v2' const LEGACY_TILES_KEY = 'hermes.desktop.sessionTiles.v1' +const TILE_PANE_PREFIX = 'session-tile:' const BOTS_TILE_BUCKET = '__bots_workspace__' /** Persisted placement — `dir` + strip slot (`before`) + dock `anchor` so a @@ -3045,9 +3046,13 @@ export function reopenLastClosedTile(): void { // timer / model) reads these instead of the primary-only atoms. // --------------------------------------------------------------------------- -// Re-exported from session-focus.ts so existing consumers keep their import -// path; the definitions live there (see the note on that module). -export { $focusedSessionIsTile, $focusedStoredSessionId } +export const $focusedSessionIsTile = computed($focusedTreePaneId, active => + Boolean(active?.startsWith(TILE_PANE_PREFIX)) +) + +export const $focusedStoredSessionId = computed([$focusedTreePaneId, $selectedStoredSessionId], (active, selected) => + active?.startsWith(TILE_PANE_PREFIX) ? active.slice(TILE_PANE_PREFIX.length) : selected +) /** Every session currently OPEN as a surface: the primary's selection plus * every tile's stored id. The sidebar highlights all of them (the focused one