Revert "fix(desktop): scope preview tabs to the session that opened them"

This reverts commit b3e896246f.
This commit is contained in:
teknium1
2026-10-01 12:17:50 -07:00
committed by Teknium
parent f5cab618c1
commit c1c065567f
14 changed files with 53 additions and 611 deletions

View File

@@ -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<string | null>(null)
nextSessionTileForWorkspace: () => nextSessionTileForWorkspace()
}))
vi.mock('@/store/profile', () => ({

View File

@@ -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)
})
})

View File

@@ -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)

View File

@@ -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)

View File

@@ -55,8 +55,6 @@ export const arArtifacts = {
},
preview: {
tab: 'معاينة',
pin: 'تثبيت في مساحة العمل',
unpin: 'إلغاء التثبيت من مساحة العمل',
closePane: 'إغلاق جزء المعاينة',
loading: 'جار تحميل المعاينة',
unavailable: 'المعاينة غير متاحة',

View File

@@ -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',

View File

@@ -3585,8 +3585,6 @@ export const ja = defineLocale({
preview: {
tab: 'プレビュー',
pin: 'ワークスペースにピン留め',
unpin: 'ワークスペースからピン留めを外す',
closePane: 'プレビューペインを閉じる',
loading: 'プレビューを読み込み中',
unavailable: 'プレビューは利用できません',

View File

@@ -4110,8 +4110,6 @@ export interface Translations {
preview: {
tab: string
pin: string
unpin: string
closePane: string
loading: string
unavailable: string

View File

@@ -58,8 +58,6 @@ export const zhHantArtifacts = {
preview: {
tab: '預覽',
pin: '固定到工作區',
unpin: '從工作區取消固定',
closePane: '關閉預覽窗格',
loading: '正在載入預覽',
unavailable: '預覽不可用',

View File

@@ -4557,8 +4557,6 @@ export const zh = defineLocale({
preview: {
tab: '预览',
pin: '固定到工作区',
unpin: '从工作区取消固定',
closePane: '关闭预览面板',
loading: '正在加载预览',
unavailable: '预览不可用',

View File

@@ -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')
})
})

View File

@@ -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<string, PreviewTab>()
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<string, PreviewTab>()
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<PreviewServerRestart | null>(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

View File

@@ -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:<storedId>` 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
)

View File

@@ -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