Revert "fix(desktop): scope preview tabs to the session that opened them"
This reverts commit b3e896246f.
This commit is contained in:
@@ -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', () => ({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -55,8 +55,6 @@ export const arArtifacts = {
|
||||
},
|
||||
preview: {
|
||||
tab: 'معاينة',
|
||||
pin: 'تثبيت في مساحة العمل',
|
||||
unpin: 'إلغاء التثبيت من مساحة العمل',
|
||||
closePane: 'إغلاق جزء المعاينة',
|
||||
loading: 'جار تحميل المعاينة',
|
||||
unavailable: 'المعاينة غير متاحة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3585,8 +3585,6 @@ export const ja = defineLocale({
|
||||
|
||||
preview: {
|
||||
tab: 'プレビュー',
|
||||
pin: 'ワークスペースにピン留め',
|
||||
unpin: 'ワークスペースからピン留めを外す',
|
||||
closePane: 'プレビューペインを閉じる',
|
||||
loading: 'プレビューを読み込み中',
|
||||
unavailable: 'プレビューは利用できません',
|
||||
|
||||
@@ -4110,8 +4110,6 @@ export interface Translations {
|
||||
|
||||
preview: {
|
||||
tab: string
|
||||
pin: string
|
||||
unpin: string
|
||||
closePane: string
|
||||
loading: string
|
||||
unavailable: string
|
||||
|
||||
@@ -58,8 +58,6 @@ export const zhHantArtifacts = {
|
||||
|
||||
preview: {
|
||||
tab: '預覽',
|
||||
pin: '固定到工作區',
|
||||
unpin: '從工作區取消固定',
|
||||
closePane: '關閉預覽窗格',
|
||||
loading: '正在載入預覽',
|
||||
unavailable: '預覽不可用',
|
||||
|
||||
@@ -4557,8 +4557,6 @@ export const zh = defineLocale({
|
||||
|
||||
preview: {
|
||||
tab: '预览',
|
||||
pin: '固定到工作区',
|
||||
unpin: '从工作区取消固定',
|
||||
closePane: '关闭预览面板',
|
||||
loading: '正在加载预览',
|
||||
unavailable: '预览不可用',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user