feat(desktop): define workspace-scoped pane ownership

This commit is contained in:
David Dudok de Wit
2026-08-22 23:18:34 +02:00
committed by Teknium
parent 613244cbb1
commit b8b6f43280
20 changed files with 881 additions and 41 deletions
@@ -0,0 +1,80 @@
import { atom } from 'nanostores'
import { afterEach, describe, expect, it } from 'vitest'
import { registry } from '@/contrib/registry'
import { paneMirror } from './pane-mirror'
interface Tile {
id: string
owner?: string
}
const cleanupSources: Array<ReturnType<typeof atom<Tile[]>>> = []
let sequence = 0
function setup(options: {
workspaceMode?: 'sessions' | 'bots' | ((tile: Tile) => 'sessions' | 'bots' | undefined)
workspaceOwnerKey?: string | ((tile: Tile) => string | undefined)
}) {
const source = atom<Tile[]>([])
const prefix = `pane-mirror-scope-${sequence++}`
cleanupSources.push(source)
paneMirror<Tile>({
source,
key: tile => tile.id,
prefix,
minWidth: '10rem',
title: key => key,
render: () => null,
close: () => undefined,
...options
})()
return {
source,
contribution: (id: string) => registry.getArea('panes').find(entry => entry.id === `${prefix}:${id}`)
}
}
afterEach(() => {
for (const source of cleanupSources.splice(0)) {
source.set([])
}
})
describe('paneMirror workspace scope', () => {
it('forwards a static workspace mode', () => {
const mirror = setup({ workspaceMode: 'sessions' })
mirror.source.set([{ id: 'one' }])
expect(mirror.contribution('one')).toMatchObject({
workspaceMode: 'sessions',
workspaceOwnerKey: undefined
})
})
it('resolves owner callbacks per tile and refreshes an unchanged title', () => {
const mirror = setup({
workspaceMode: 'bots',
workspaceOwnerKey: tile => tile.owner
})
mirror.source.set([{ id: 'one', owner: 'connection-a::default' }])
expect(mirror.contribution('one')?.workspaceOwnerKey).toBe('connection-a::default')
mirror.source.set([{ id: 'one', owner: 'connection-b::default' }])
expect(mirror.contribution('one')?.workspaceOwnerKey).toBe('connection-b::default')
})
it('leaves existing callers unscoped when options are omitted', () => {
const mirror = setup({})
mirror.source.set([{ id: 'one' }])
expect(mirror.contribution('one')).toMatchObject({
workspaceMode: undefined,
workspaceOwnerKey: undefined
})
})
})
+27 -4
View File
@@ -11,13 +11,23 @@ import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from
import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store'
import { registry } from '@/contrib/registry'
import type { WorkspaceMode } from '@/contrib/types'
import type { TileDock } from '@/store/session-states'
type WorkspaceValue<T, V> = V | ((tile: T) => V | undefined)
const workspaceValue = <T, V>(value: WorkspaceValue<T, V> | undefined, tile: T): V | undefined =>
typeof value === 'function' ? (value as (tile: T) => V | undefined)(tile) : value
export interface PaneMirror<T> {
/** Reactive source list. */
source: ReadableAtom<T[]>
/** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */
also?: ReadableAtom<unknown>[]
/** Workspace surface this tile belongs to. Omit for a global pane. */
workspaceMode?: WorkspaceValue<T, WorkspaceMode>
/** Exact opaque owner inside Bot Mode. Omit outside an owner-scoped pane. */
workspaceOwnerKey?: WorkspaceValue<T, string>
/** Stable key + pane-id seed for a tile. */
key: (tile: T) => string
/** Pane-id namespace — the id is `${prefix}:${key}`. */
@@ -51,7 +61,11 @@ export interface PaneMirror<T> {
/** Build a `watch*` fn: syncs once, then re-syncs on every source/also change.
* Module-level state lives in the returned closure, so call it once per app. */
export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
const registered = new Map<string, { dispose: () => void; title: string }>()
const registered = new Map<
string,
{ dispose: () => void; title: string; workspaceMode?: WorkspaceMode; workspaceOwnerKey?: string }
>()
const paneId = (key: string) => `${cfg.prefix}:${key}`
const sync = () => {
@@ -61,10 +75,17 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
for (const tile of tiles) {
const key = cfg.key(tile)
const title = cfg.title(key)
const workspaceMode = workspaceValue(cfg.workspaceMode, tile)
const workspaceOwnerKey = workspaceValue(cfg.workspaceOwnerKey, tile)
const current = registered.get(key)
// register() replaces same-id in place — safe for live title refreshes.
if (current && current.title === title) {
if (
current &&
current.title === title &&
current.workspaceMode === workspaceMode &&
current.workspaceOwnerKey === workspaceOwnerKey
) {
continue
}
@@ -90,10 +111,12 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
: undefined, // returns boolean (handled) — see PaneChrome.tabDrag
tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined
},
render: () => cfg.render(key)
render: () => cfg.render(key),
workspaceMode,
workspaceOwnerKey
})
registered.set(key, { dispose, title })
registered.set(key, { dispose, title, workspaceMode, workspaceOwnerKey })
if (!current) {
registerPaneCloser(paneId(key), () => cfg.close(key))
@@ -122,6 +122,7 @@ export function watchPreviewTiles(): void {
const watchPreviewTileMirror = paneMirror<{ id: string }>({
source: $previewTabs,
workspaceMode: 'sessions',
key: tab => tab.id,
prefix: PREVIEW_TILE_PREFIX,
// Identical to route (page) tiles: its own zone docked beside main, sized by
+1
View File
@@ -86,6 +86,7 @@ function RouteTilePane({ path }: { path: string }) {
/** Keep pane contributions mirroring `$routeTiles`. Call once from the root. */
export const watchRouteTiles = paneMirror<RouteTile>({
source: $routeTiles,
workspaceMode: 'sessions',
key: t => t.path,
prefix: 'route-tile',
dir: t => t.dir,
@@ -595,6 +595,8 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
export const watchSessionTiles = paneMirror<SessionTile>({
source: $sessionTiles,
workspaceMode: tile => tile.workspaceMode ?? 'sessions',
workspaceOwnerKey: tile => tile.workspaceOwnerKey,
// $projectTree: a tile whose session is older than the recents page resolves
// its title through the tree, which loads after the tiles register. (The tab's
// status dot subscribes to color/state itself, so it needs no `also` entry.)
@@ -176,6 +176,7 @@ registry.registerMany([
{
id: 'workspace',
area: 'panes',
workspaceMode: 'sessions',
// Live-retitled to the loaded session by syncWorkspaceTitle below.
title: NEW_SESSION_TITLE,
data: {
@@ -482,6 +483,7 @@ const syncWorkspaceTitle = () => {
registry.register({
id: 'workspace',
area: 'panes',
workspaceMode: 'sessions',
// The placeholder, not the draft's live name — `tabTitle` below renders
// that. Keeping it here would re-register the pane on every keystroke.
title: stored ? storedSessionTitle(stored) : NEW_SESSION_TITLE,
+14 -1
View File
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const focusOpenSession = vi.fn()
const openSessionTile = vi.fn()
const reuseBlankDraftTile = vi.fn()
const setSessionTileWorkspaceScope = vi.fn()
const openSessionInNewWindow = vi.fn()
const canOpenSessionWindow = vi.fn(() => true)
const workspaceIsPageGet = vi.fn(() => false)
@@ -12,7 +13,8 @@ vi.mock('@/store/session-states', () => ({
!focused || (focused === 'main' && workspaceIsPage),
focusOpenSession: (...args: unknown[]) => focusOpenSession(...args),
openSessionTile: (...args: unknown[]) => openSessionTile(...args),
reuseBlankDraftTile: (...args: unknown[]) => reuseBlankDraftTile(...args)
reuseBlankDraftTile: (...args: unknown[]) => reuseBlankDraftTile(...args),
setSessionTileWorkspaceScope: (...args: unknown[]) => setSessionTileWorkspaceScope(...args)
}))
vi.mock('@/store/windows', () => ({
@@ -89,6 +91,7 @@ describe('openSession', () => {
canOpenSessionWindow.mockReturnValue(true)
workspaceIsPageGet.mockReturnValue(false)
reuseBlankDraftTile.mockReset()
setSessionTileWorkspaceScope.mockReset()
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
})
@@ -143,6 +146,16 @@ describe('openSession', () => {
expect(navigate).not.toHaveBeenCalled()
})
it('threads an exact Bot owner into a new session tile', () => {
const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' }
focusOpenSession.mockReturnValue(null)
openSession('s1', navigate, 'tab', scope)
expect(setSessionTileWorkspaceScope).toHaveBeenCalledWith('s1', scope)
expect(openSessionTile).toHaveBeenCalledWith('s1', 'center', undefined, undefined, scope)
})
it('stack focuses a session that is already on screen', () => {
$selectedStoredSessionId.set('s0')
focusOpenSession.mockReturnValue('tile')
+23 -4
View File
@@ -14,12 +14,14 @@
* - `window` (⇧⌘-click) — pop into its own window; falls back to `tab` when
* the bridge has no session-window support.
*/
import type { WorkspaceMode } from '@/contrib/types'
import { $activeSessionId, $selectedStoredSessionId, markSessionRead } from '@/store/session'
import {
focusedSessionNeedsRoute,
focusOpenSession,
openSessionTile,
reuseBlankDraftTile
reuseBlankDraftTile,
setSessionTileWorkspaceScope
} from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
@@ -29,6 +31,11 @@ export type OpenSessionIntent = 'in-place' | 'main' | 'stack' | 'tab' | 'window'
export type OpenSessionNavigate = (to: string, options?: { replace?: boolean }) => void
export interface OpenSessionWorkspaceScope {
workspaceMode: WorkspaceMode
workspaceOwnerKey?: string
}
/**
* Is the main tab holding a conversation worth preserving?
*
@@ -72,7 +79,8 @@ export function openSessionIntentFromModifiers(
export function openSession(
storedSessionId: string,
navigate: OpenSessionNavigate,
intent: OpenSessionIntent = 'in-place'
intent: OpenSessionIntent = 'in-place',
workspaceScope: OpenSessionWorkspaceScope = { workspaceMode: 'sessions' }
): void {
if (!storedSessionId) {
return
@@ -83,6 +91,8 @@ export function openSession(
// already on screen (open tile, or the main session) would otherwise return
// at focusOpenSession and never clear its unread dot.
markSessionRead(storedSessionId)
setSessionTileWorkspaceScope(storedSessionId, workspaceScope)
const botWorkspaceScope = workspaceScope.workspaceMode === 'bots' ? workspaceScope : undefined
let resolved: OpenSessionIntent = intent
@@ -128,11 +138,20 @@ export function openSession(
// Nothing to jump to, but an open tab may still be an empty "New session" —
// that's the tab the user would have typed into, so spend it rather than
// stacking a second blank one beside it.
if (spendBlankDraft && reuseBlankDraftTile(storedSessionId)) {
if (
spendBlankDraft &&
(botWorkspaceScope
? reuseBlankDraftTile(storedSessionId, botWorkspaceScope)
: reuseBlankDraftTile(storedSessionId))
) {
return
}
openSessionTile(storedSessionId, 'center')
if (botWorkspaceScope) {
openSessionTile(storedSessionId, 'center', undefined, undefined, botWorkspaceScope)
} else {
openSessionTile(storedSessionId, 'center')
}
return
}
@@ -10,7 +10,7 @@
*/
import { useStore } from '@nanostores/react'
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react'
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'
import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
import { Codicon } from '@/components/ui/codicon'
@@ -35,6 +35,14 @@ import { $layoutEditMode } from '../../edit-mode'
import { useWindowControlsOverlap } from '../../geometry'
import { emptyPaneLifecycleState, reconcilePaneLifecycle } from '../../pane-lifecycle'
import { hiddenPaneProps, PaneGroupContext, PaneLifecycleContext, PaneVisibleContext } from '../../pane-visibility'
import {
$workspaceMode,
$workspaceOwnerKey,
contributesToWorkspace,
rememberActivePane,
resolveRememberedActivePane,
workspaceScopeKey
} from '../../workspace-scope'
import type { DropPosition, GroupNode } from '../model'
import {
$dropHint,
@@ -228,6 +236,8 @@ export function TreeGroup({
const hiddenPanes = useStore($hiddenTreePanes)
const narrow = useStore($narrowViewport)
const workspaceMode = useStore($workspaceMode)
const workspaceOwnerKey = useStore($workspaceOwnerKey)
const newSessionTabAction = useStore($newSessionTabAction)
const panesWithCloser = useStore($panesWithCloser)
// Multi-tab selection (⌥/Ctrl-click, Shift-click) — null for every zone but
@@ -245,12 +255,26 @@ export function TreeGroup({
// Edit mode forces toggle-hidden panes visible so they can be rearranged
// (mirrors tree-split's paneGone) — restores itself on exit.
const paneShown = (id: string) =>
Boolean(paneFor(id)) && (editMode || !hiddenPanes.has(id)) && !(narrow && paneChrome(paneFor(id)).collapsible)
Boolean(paneFor(id)) &&
contributesToWorkspace(paneFor(id), workspaceMode, workspaceOwnerKey) &&
(editMode || !hiddenPanes.has(id)) &&
!(narrow && paneChrome(paneFor(id)).collapsible)
const shown = node.panes.filter(paneShown)
const activeId = shown.includes(node.active) ? node.active : (shown[0] ?? node.active)
const memoryKey = workspaceScopeKey(workspaceMode, workspaceOwnerKey)
const activeId = shown.includes(node.active)
? node.active
: (resolveRememberedActivePane(memoryKey, shown) ?? shown[0] ?? '')
const active = paneFor(activeId)
const isEmpty = node.panes.length === 0
const isEmpty = shown.length === 0
useEffect(() => {
if (activeId) {
rememberActivePane(memoryKey, activeId)
}
}, [activeId, memoryKey])
// BOUNDED KEEP-ALIVE: the active pane is visible, a small per-zone LRU stays
// hot-hidden, and older panes park (unmount). This preserves fast tab
@@ -17,6 +17,7 @@ import { cn } from '@/lib/utils'
import { $paneStates, type PaneStateSnapshot, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes'
import { $layoutEditMode } from '../../edit-mode'
import { $workspaceMode, $workspaceOwnerKey, contributesToWorkspace } from '../../workspace-scope'
import type { LayoutNode, SplitNode } from '../model'
import { allPaneIds } from '../model'
import {
@@ -90,6 +91,8 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
const panes = useContributions('panes')
const hiddenPanes = useStore($hiddenTreePanes)
const narrow = useStore($narrowViewport)
const workspaceMode = useStore($workspaceMode)
const workspaceOwnerKey = useStore($workspaceOwnerKey)
// Scoped to THIS subtree's panes: a sash drag writes size overrides on every
// pointermove, but only the splits whose subtree actually resized should
// re-render — not every split in the tree.
@@ -124,7 +127,10 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
// closed) visible so they're rearrangeable — only truly-absent (unregistered)
// or narrow-collapsed panes stay gone. Restores itself on exit (render-only).
const paneGone = (id: string) =>
!paneFor(id) || (!editMode && hiddenPanes.has(id)) || (narrow && Boolean(paneChrome(paneFor(id)).collapsible))
!paneFor(id) ||
!contributesToWorkspace(paneFor(id), workspaceMode, workspaceOwnerKey) ||
(!editMode && hiddenPanes.has(id)) ||
(narrow && Boolean(paneChrome(paneFor(id)).collapsible))
const trackCtx: TrackContext = { paneFor, paneGone, overrides }
@@ -0,0 +1,96 @@
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope'
import { registry } from '@/contrib/registry'
import type { GroupNode } from '../model'
import { TreeGroup } from './tree-group'
let root: null | Root = null
let container: HTMLDivElement | null = null
const disposers: Array<() => void> = []
function render(ui: ReactNode) {
if (!container) {
container = globalThis.document.createElement('div')
globalThis.document.body.append(container)
root = createRoot(container)
}
act(() => root!.render(ui))
}
function register(id: string, title: string, scope: { workspaceMode?: 'sessions' | 'bots'; workspaceOwnerKey?: string } = {}) {
disposers.push(
registry.register({
area: 'panes',
data: { placement: 'main' },
id,
render: () => <div>{title} content</div>,
title,
...scope
})
)
}
const group = (active: string): GroupNode => ({
active,
id: 'workspace-scope-zone',
panes: ['session-a', 'bot-a', 'bot-b'],
tabStrip: 'always',
type: 'group'
})
const visibleTabs = () =>
[...globalThis.document.querySelectorAll<HTMLElement>('[data-tree-tab]')].map(tab => tab.dataset.treeTab)
afterEach(() => {
if (root) {
act(() => root!.unmount())
}
container?.remove()
disposers.splice(0).forEach(dispose => dispose())
act(() => {
setWorkspaceScope('sessions')
})
root = null
container = null
vi.unstubAllGlobals()
})
describe('TreeGroup workspace scope', () => {
it('renders only the current workspace owner and restores owner activity', () => {
vi.stubGlobal('CSS', { escape: (value: string) => value })
register('session-a', 'Session A', { workspaceMode: 'sessions' })
register('bot-a', 'Bot A', { workspaceMode: 'bots', workspaceOwnerKey: 'connection-a::default' })
register('bot-b', 'Bot B', { workspaceMode: 'bots', workspaceOwnerKey: 'connection-b::default' })
render(<TreeGroup node={group('session-a')} parentAxis="column" />)
expect(visibleTabs()).toEqual(['session-a'])
expect(container?.textContent).toContain('Session A content')
act(() => setWorkspaceScope('bots', 'connection-a::default'))
render(<TreeGroup node={group('bot-a')} parentAxis="column" />)
expect(visibleTabs()).toEqual(['bot-a'])
expect(container?.textContent).toContain('Bot A content')
act(() => setWorkspaceScope('bots', 'connection-b::default'))
render(<TreeGroup node={group('bot-b')} parentAxis="column" />)
expect(visibleTabs()).toEqual(['bot-b'])
expect(container?.textContent).toContain('Bot B content')
act(() => setWorkspaceScope('bots', 'connection-a::default'))
render(<TreeGroup node={group('bot-b')} parentAxis="column" />)
expect(visibleTabs()).toEqual(['bot-a'])
expect(container?.textContent).toContain('Bot A content')
act(() => setWorkspaceScope('sessions'))
render(<TreeGroup node={group('bot-b')} parentAxis="column" />)
expect(visibleTabs()).toEqual(['session-a'])
expect(container?.textContent).toContain('Session A content')
})
})
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
$workspaceMode,
$workspaceOwnerKey,
contributesToWorkspace,
filterContributionsForWorkspace,
forgetActivePane,
forgetRememberedPane,
rememberActivePane,
resetRememberedActivePanes,
resolveRememberedActivePane,
setWorkspaceScope
} from './workspace-scope'
interface ScopedContribution {
id: string
workspaceMode?: 'sessions' | 'bots'
workspaceOwnerKey?: string
}
const contribution = (
id: string,
scope?: Pick<ScopedContribution, 'workspaceMode' | 'workspaceOwnerKey'>
): ScopedContribution => ({ id, ...scope })
const bot = (ownerKey: string, suffix: string) =>
contribution(`bot:${suffix}`, {
workspaceMode: 'bots',
workspaceOwnerKey: ownerKey
})
afterEach(() => {
setWorkspaceScope('sessions')
})
describe('workspace scope', () => {
it('defaults to the un-switched sessions window state', () => {
expect($workspaceMode.get()).toBe('sessions')
expect($workspaceOwnerKey.get()).toBeNull()
})
it('publishes a coherent mode and owner in one batch', () => {
const snapshots: Array<['sessions' | 'bots', string | null]> = []
const capture = () => snapshots.push([$workspaceMode.get(), $workspaceOwnerKey.get()])
const unbindMode = $workspaceMode.listen(capture)
const unbindOwner = $workspaceOwnerKey.listen(capture)
snapshots.length = 0
expect(setWorkspaceScope('bots', 'connection-a::default')).toBe(true)
expect(snapshots.length).toBeGreaterThan(0)
expect(snapshots.every(snapshot => snapshot[0] === 'bots' && snapshot[1] === 'connection-a::default')).toBe(true)
expect(setWorkspaceScope('bots', 'connection-a::default')).toBe(false)
unbindMode()
unbindOwner()
})
it('keeps global contributions visible in both modes', () => {
expect(contributesToWorkspace(undefined, 'sessions', null)).toBe(true)
expect(contributesToWorkspace(undefined, 'bots', 'bot-a')).toBe(true)
})
it('separates sessions and bots contributions by mode', () => {
const sessionsOnly = contribution('sessions-pane', { workspaceMode: 'sessions' })
const botsOnly = bot('bot-a', 'pane')
expect(contributesToWorkspace(sessionsOnly, 'sessions')).toBe(true)
expect(contributesToWorkspace(sessionsOnly, 'bots', 'bot-a')).toBe(false)
expect(contributesToWorkspace(botsOnly, 'sessions')).toBe(false)
expect(contributesToWorkspace(botsOnly, 'bots', 'bot-a')).toBe(true)
})
it('requires an exact non-empty owner match for bots contributions', () => {
const scoped = bot('bot-a', 'pane')
expect(contributesToWorkspace(scoped, 'bots', 'bot-b')).toBe(false)
expect(contributesToWorkspace(scoped, 'bots', null)).toBe(false)
expect(contributesToWorkspace(scoped, 'bots', '')).toBe(false)
expect(contributesToWorkspace(scoped, 'bots', 'bot-a')).toBe(true)
// A bots-scoped contribution with an empty owner key never participates.
const noOwner = contribution('no-owner', { workspaceMode: 'bots' })
expect(contributesToWorkspace(noOwner, 'bots', 'bot-a')).toBe(false)
})
it('does not collide on shared profile suffixes across connection-qualified keys', () => {
// Same profile suffix, different connections — opaque exact strings only.
const localProfile = bot('local:main', 'main')
const remoteProfile = bot('ssh:server:main', 'main')
expect(contributesToWorkspace(localProfile, 'bots', 'ssh:server:main')).toBe(false)
expect(contributesToWorkspace(remoteProfile, 'bots', 'local:main')).toBe(false)
expect(contributesToWorkspace(localProfile, 'bots', 'local:main')).toBe(true)
expect(contributesToWorkspace(remoteProfile, 'bots', 'ssh:server:main')).toBe(true)
})
})
describe('filterContributionsForWorkspace', () => {
it('filters to the current mode and preserves input order', () => {
const contributions = [
bot('bot-a', 'zeta'),
contribution('global-1'),
contribution('sessions-only', { workspaceMode: 'sessions' }),
bot('bot-a', 'alpha')
]
expect(filterContributionsForWorkspace(contributions, 'bots', 'bot-a').map(c => c.id)).toEqual([
'bot:zeta',
'global-1',
'bot:alpha'
])
expect(
filterContributionsForWorkspace(contributions, 'sessions', null).map(c => c.id)
).toEqual(['global-1', 'sessions-only'])
})
it('returns the original array reference on a no-op', () => {
const contributions: ScopedContribution[] = [contribution('a'), contribution('b')]
expect(filterContributionsForWorkspace(contributions, 'sessions', null)).toBe(contributions)
expect(filterContributionsForWorkspace(contributions, 'bots', 'anything').length).toBe(2)
})
})
describe('remembered active panes', () => {
beforeEach(() => resetRememberedActivePanes())
it('remembers and restores panes independently per owner key', () => {
rememberActivePane('conn-a:profile-x', 'pane-1')
rememberActivePane('conn-b:profile-y', 'pane-2')
expect(resolveRememberedActivePane('conn-a:profile-x', ['pane-1', 'pane-2'])).toBe('pane-1')
expect(resolveRememberedActivePane('conn-b:profile-y', ['pane-1', 'pane-2'])).toBe('pane-2')
})
it('does not collide on a shared profile suffix across owner keys', () => {
rememberActivePane('local:main', 'pane-local')
expect(resolveRememberedActivePane('ssh:server:main', [])).toBeNull()
})
it('falls back after the remembered pane is removed', () => {
rememberActivePane('bot-a', 'pane-gone')
expect(resolveRememberedActivePane('bot-a', ['first', 'second'])).toBe('first')
expect(resolveRememberedActivePane('bot-a', [])).toBeNull()
})
it('forgets a single owner without touching others', () => {
rememberActivePane('bot-a', 'pane-a')
rememberActivePane('bot-b', 'pane-b')
forgetActivePane('bot-a')
expect(resolveRememberedActivePane('bot-a', ['fallback-a', 'pane-a'])).toBe('fallback-a')
expect(resolveRememberedActivePane('bot-b', ['pane-a', 'pane-b'])).toBe('pane-b')
})
it('forgets a removed pane across every owner that remembered it', () => {
rememberActivePane('bot-a', 'pane-gone')
rememberActivePane('bot-b', 'pane-gone')
forgetRememberedPane('pane-gone')
expect(resolveRememberedActivePane('bot-a', ['fallback-a'])).toBe('fallback-a')
expect(resolveRememberedActivePane('bot-b', ['fallback-b'])).toBe('fallback-b')
})
})
@@ -0,0 +1,167 @@
/**
* Workspace scoping for contributions.
*
* Pure presentation-ownership helpers: which workspace surface (sessions vs
* bots) a contribution belongs to, and — within the bots surface — which exact
* bot it belongs to. Owner keys are opaque exact strings supplied by callers;
* this module never parses profile names or infers connections.
*
* No persistence here by design: the remembered active-pane map is window-local
* memory so a switch away and back can restore where the user was, without any
* of it surviving the window.
*/
import { atom, batch } from 'nanostores'
import type { WorkspaceMode } from '../../contrib/types'
/** Re-exported so workspace consumers can import it from here. */
export type { WorkspaceMode } from '../../contrib/types'
/** Default workspace mode when the host has not switched surfaces. */
export const $workspaceMode = atom<WorkspaceMode>('sessions')
/** Default workspace owner key: none (unscoped / global ownership). */
export const $workspaceOwnerKey = atom<string | null>(null)
/** One key for window-local active-pane memory. Owner keys stay opaque. */
export function workspaceScopeKey(mode: WorkspaceMode, ownerKey: string | null): string {
return mode === 'sessions' ? 'sessions' : `bots:${ownerKey ?? ''}`
}
/** Publish one coherent presentation scope without an intermediate mixed frame. */
export function setWorkspaceScope(mode: WorkspaceMode, ownerKey: string | null = null): boolean {
const nextOwnerKey = mode === 'bots' ? ownerKey : null
if ($workspaceMode.get() === mode && $workspaceOwnerKey.get() === nextOwnerKey) {
return false
}
batch(() => {
$workspaceMode.set(mode)
$workspaceOwnerKey.set(nextOwnerKey)
})
return true
}
/**
* The slice of {@link Contribution} metadata that scopes it to a workspace.
* A contribution with neither field set is global: it participates in every
* workspace, preserving pre-existing behavior.
*/
export interface WorkspaceScope {
/** Surface this contribution belongs to. Omit for global visibility. */
workspaceMode?: WorkspaceMode
/** Exact opaque owner key within the `'bots'` surface. Ignored otherwise. */
workspaceOwnerKey?: string
}
/**
* Whether a contribution participates in the given workspace.
*
* - Unscoped/global (no `workspaceMode`) => always participates.
* - Scoped with a mode mismatch => does not participate.
* - Sessions match => participates.
* - Bots match => participates only when the owner key is non-empty and equals
* the current owner key (exact string equality).
*
* Defaults reflect the un-switched window state when omitted.
*/
export function contributesToWorkspace(
scope: WorkspaceScope | undefined,
mode: WorkspaceMode = $workspaceMode.get(),
ownerKey: string | null = $workspaceOwnerKey.get()
): boolean {
const { workspaceMode, workspaceOwnerKey } = scope ?? {}
if (workspaceMode == null) {
return true
}
if (workspaceMode !== mode) {
return false
}
if (workspaceMode === 'sessions') {
return true
}
return Boolean(workspaceOwnerKey) && workspaceOwnerKey === ownerKey
}
/**
* Filter contributions down to those participating in the given workspace,
* preserving input order.
*
* Preserves reference identity on a no-op (every contribution participates),
* so callers can hand the result straight to React without a wasted re-render.
*/
export function filterContributionsForWorkspace<T extends WorkspaceScope>(
contributions: readonly T[],
mode: WorkspaceMode,
ownerKey: string | null
): readonly T[] {
let filtered: T[] | null = null
for (let i = 0; i < contributions.length; i += 1) {
if (contributesToWorkspace(contributions[i], mode, ownerKey)) {
filtered?.push(contributions[i])
continue
}
filtered ??= contributions.slice(0, i)
}
return filtered ?? contributions
}
/**
* Window-local memory of the active pane per exact workspace owner key.
* Keys are opaque exact strings; similar-looking keys never collide because
* nothing here parses them.
*/
const rememberedActivePanes = new Map<string, string>()
/** Remember which pane was active for an exact owner key. */
export function rememberActivePane(ownerKey: string, paneId: string): void {
rememberedActivePanes.set(ownerKey, paneId)
}
/**
* Resolve the pane to activate for an owner key against the currently eligible
* panes. A remembered pane that has since been removed must not restore: the
* fallback is the first eligible pane, or null when none are eligible.
*/
export function resolveRememberedActivePane(
ownerKey: string,
eligiblePaneIds: readonly string[]
): string | null {
const remembered = rememberedActivePanes.get(ownerKey)
if (remembered != null && eligiblePaneIds.includes(remembered)) {
return remembered
}
return eligiblePaneIds[0] ?? null
}
/** Forget the remembered pane for one owner key. */
export function forgetActivePane(ownerKey: string): void {
rememberedActivePanes.delete(ownerKey)
}
/** Forget a pane removed from the layout, regardless of which owners used it. */
export function forgetRememberedPane(paneId: string): void {
for (const [ownerKey, rememberedPaneId] of rememberedActivePanes) {
if (rememberedPaneId === paneId) {
rememberedActivePanes.delete(ownerKey)
}
}
}
/** Test-only: clear all remembered panes. */
export function resetRememberedActivePanes(): void {
rememberedActivePanes.clear()
}
+1 -1
View File
@@ -3,4 +3,4 @@ export type { SlotProps } from './react/slot'
export { useContributions } from './react/use-contributions'
export { registry } from './registry'
export type { Contribution, ContributionSource } from './types'
export type { Contribution, ContributionSource, WorkspaceMode } from './types'
+19
View File
@@ -9,6 +9,13 @@ import type { ReactNode } from 'react'
*/
export type ContributionSource = 'core' | (string & {})
/**
* Which workspace surface a contribution targets. `'sessions'` is the classic
* session workspace; `'bots'` scopes content to an individual bot identified
* by an opaque owner key.
*/
export type WorkspaceMode = 'sessions' | 'bots'
/**
* The single, uniform primitive every surface consumes. A bar renders these as
* inline items via `<Slot>`; a dock renders them as stacked/tabbed panes via
@@ -40,4 +47,16 @@ export interface Contribution {
* themes, commands — anything consumed by an engine rather than rendered.
*/
data?: unknown
/**
* Which workspace surface this contribution belongs to. Omit for a global
* contribution that participates in every workspace (pre-existing behavior).
* Presentation ownership hint only.
*/
workspaceMode?: WorkspaceMode
/**
* Exact opaque owner key within the `'bots'` surface (e.g. a
* connection-qualified profile id). Never parsed here; compared exactly.
* Presentation ownership hint only.
*/
workspaceOwnerKey?: string
}
+42
View File
@@ -173,3 +173,45 @@ describe('host.connections', () => {
await expect(host.connections()).rejects.toThrow('This Desktop build has no connection registry')
})
})
describe('host workspace scope', () => {
afterEach(async () => {
host.setWorkspaceScope('sessions')
const tree = await import('@/components/pane-shell/tree/store')
tree.removeTreePane('plugin-workspace:scope-test')
})
it('registers plugin workspace ownership and chrome options', async () => {
const { registry } = await import('@/contrib/registry')
const close = host.openWorkspace('scope-test', {
dock: { pane: 'workspace', pos: 'right' },
headerVeto: true,
render: () => null,
title: 'Scoped',
uncloseable: true,
workspaceMode: 'bots',
workspaceOwnerKey: 'connection-a::default'
})
expect(registry.getArea('panes').find(pane => pane.id === 'plugin-workspace:scope-test')).toMatchObject({
data: {
dock: { pane: 'workspace', pos: 'right' },
headerVeto: true,
uncloseable: true
},
workspaceMode: 'bots',
workspaceOwnerKey: 'connection-a::default'
})
close()
})
it('publishes the active workspace scope through one host seam', async () => {
const { $workspaceMode, $workspaceOwnerKey } = await import('@/components/pane-shell/workspace-scope')
expect(host.setWorkspaceScope('bots', 'connection-b::default')).toBe(true)
expect($workspaceMode.get()).toBe('bots')
expect($workspaceOwnerKey.get()).toBe('connection-b::default')
})
})
+45 -16
View File
@@ -31,8 +31,10 @@ import {
removeTreePane,
revealTreePane
} from '@/components/pane-shell/tree/store'
import { setWorkspaceScope as publishWorkspaceScope } from '@/components/pane-shell/workspace-scope'
import { onGatewayEvent } from '@/contrib/events'
import { registry } from '@/contrib/registry'
import type { WorkspaceMode } from '@/contrib/types'
import { deleteProfile, getLogs, getStatus, type HermesGateway } from '@/hermes'
import {
$gateway,
@@ -285,6 +287,8 @@ export interface PluginOpenSessionOptions {
keepAllProfilesScope?: boolean
profile?: null | string
route?: PluginProfileRoute
workspaceMode?: WorkspaceMode
workspaceOwnerKey?: string
/** A cold profile backend can lose the hydration-timeout race once and still
* be fine on a second try. When set, a hydration timeout is retried
* internally before it reaches the caller or arms the core stranded-session
@@ -730,19 +734,26 @@ export const host = {
// again inside the same wake — that is the Retry surface's job.
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
openSession(
storedSessionId,
(to: string, opts?: { replace?: boolean }) => {
const target = to.startsWith('#') ? to : `#${to}`
const navigate = (to: string, opts?: { replace?: boolean }) => {
const target = to.startsWith('#') ? to : `#${to}`
if (opts?.replace) {
window.location.replace(target)
} else {
window.location.hash = target
}
},
options.intent ?? 'in-place'
)
if (opts?.replace) {
window.location.replace(target)
} else {
window.location.hash = target
}
}
const intent = options.intent ?? 'in-place'
if (options.workspaceMode === 'bots') {
openSession(storedSessionId, navigate, intent, {
workspaceMode: 'bots',
workspaceOwnerKey: options.workspaceOwnerKey
})
} else {
openSession(storedSessionId, navigate, intent)
}
// Judge the main surface AFTER the open: on a cold start the persisted
// route can already point at this session while selection has not
@@ -846,7 +857,17 @@ export const host = {
* fallback. */
openWorkspace: (
id: string,
options: { minWidth?: string; onClose?: () => void; render: () => ReactNode; title?: string }
options: {
dock?: { before?: null | string; pane: string; pos: 'bottom' | 'center' | 'left' | 'right' | 'top' }
headerVeto?: boolean
minWidth?: string
onClose?: () => void
render: () => ReactNode
title?: string
uncloseable?: boolean
workspaceMode?: WorkspaceMode
workspaceOwnerKey?: string
}
): (() => void) => {
const key = (id ?? '').trim()
@@ -861,13 +882,17 @@ export const host = {
data: {
// The session-tile shape: a full workspace surface docked beside main,
// closeable so it keeps its tab when it lands in a zone of its own.
dock: { pane: 'workspace', pos: 'center' },
dock: options.dock ?? { pane: 'workspace', pos: 'center' },
headerVeto: options.headerVeto,
minWidth: options.minWidth ?? '22rem',
placement: 'main'
placement: 'main',
uncloseable: options.uncloseable
},
id: paneId,
render: options.render,
title: options.title ?? key
title: options.title ?? key,
workspaceMode: options.workspaceMode,
workspaceOwnerKey: options.workspaceOwnerKey
})
const close = () => {
@@ -886,6 +911,10 @@ export const host = {
return close
},
/** Switch the visible main-pane workspace without unregistering retained panes. */
setWorkspaceScope: (mode: WorkspaceMode, ownerKey: null | string = null): boolean =>
publishWorkspaceScope(mode, ownerKey),
/** Start a fresh chat draft, optionally pointed at another profile (its
* backend spins up in the background — same door the sidebar's per-profile
* "+" uses). */
@@ -413,6 +413,26 @@ describe('profile-aware plugin session opens', () => {
expect(openSessionCore).toHaveBeenCalledWith('remote-chat', expect.any(Function), 'in-place')
})
it('threads an exact Bot workspace owner into the core session open', async () => {
const route = {
connectionId: 'source-a',
mode: 'remote' as const,
profile: 'default',
targetProfile: 'backend-default'
}
await host.openSession('bot-chat', {
route,
workspaceMode: 'bots',
workspaceOwnerKey: 'source-a::default'
})
expect(openSessionCore).toHaveBeenCalledWith('bot-chat', expect.any(Function), 'in-place', {
workspaceMode: 'bots',
workspaceOwnerKey: 'source-a::default'
})
})
it('waits until the target Bot Chat runtime and history are on main before resolving', async () => {
vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined)
+67 -1
View File
@@ -12,12 +12,15 @@ import {
focusedSessionNeedsRoute,
markSelectionRestore,
nextSessionTileForWorkspace,
openSessionTile,
orderTilesByTree,
patchSessionTile,
releaseSessionTranscript,
resetTileRuntimeBindings,
selectionHomesToWorkspace,
type SessionTileDelegate,
setSessionTileDelegate
setSessionTileDelegate,
setSessionTileWorkspaceScope
} from '@/store/session-states'
const tile = (storedSessionId: string): SessionTile => ({ storedSessionId })
@@ -53,6 +56,69 @@ describe('resetTileRuntimeBindings', () => {
})
})
describe('SessionTile workspace scope', () => {
afterEach(() => {
$layoutTree.set(null)
$selectedStoredSessionId.set(null)
$sessionTiles.set([])
})
it('stores an exact Bot owner and keeps it through placement patches', () => {
const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' }
openSessionTile('bot-chat', 'right', undefined, undefined, scope)
patchSessionTile('bot-chat', { dir: 'left' })
expect($sessionTiles.get()).toEqual([
expect.objectContaining({
dir: 'left',
storedSessionId: 'bot-chat',
workspaceMode: 'bots',
workspaceOwnerKey: 'connection-a::default'
})
])
})
it('re-scopes an existing tile without changing its placement', () => {
openSessionTile('chat', 'bottom', 'workspace')
expect(
setSessionTileWorkspaceScope('chat', {
workspaceMode: 'bots',
workspaceOwnerKey: 'connection-b::default'
})
).toBe(true)
expect($sessionTiles.get()[0]).toMatchObject({
anchor: 'workspace',
dir: 'bottom',
workspaceMode: 'bots',
workspaceOwnerKey: 'connection-b::default'
})
})
it('preserves workspace scope while dropping a stale runtime binding', () => {
$sessionTiles.set([
{
runtimeId: 'runtime-dead',
storedSessionId: 'bot-chat',
workspaceMode: 'bots',
workspaceOwnerKey: 'connection-a::default'
}
])
resetTileRuntimeBindings()
expect($sessionTiles.get()[0]).toEqual({
anchor: undefined,
before: undefined,
dir: undefined,
storedSessionId: 'bot-chat',
workspaceMode: 'bots',
workspaceOwnerKey: 'connection-a::default'
})
})
})
describe('releaseSessionTranscript', () => {
afterEach(() => {
$sessionStates.set({})
+70 -9
View File
@@ -29,6 +29,7 @@ import {
noteActiveTreeGroup,
revealTreePane
} from '@/components/pane-shell/tree/store'
import type { WorkspaceMode } from '@/contrib/types'
import { stableArray } from '@/lib/stable-array'
import { readJson, writeJson } from '@/lib/storage'
import type { SessionInfo } from '@/types/hermes'
@@ -535,6 +536,15 @@ export interface SessionTile {
runtimeId?: string
/** Resume failed terminally (shown in the tile; retryable). */
error?: string
/** Presentation workspace this tab belongs to. Missing legacy values are Sessions. */
workspaceMode?: WorkspaceMode
/** Exact opaque owner key for Bot Mode tabs. */
workspaceOwnerKey?: string
}
export interface SessionTileWorkspaceScope {
workspaceMode: WorkspaceMode
workspaceOwnerKey?: string
}
// Tiles are persisted PER PROFILE: a session belongs to one profile, and the
@@ -550,13 +560,18 @@ const TILE_PANE_PREFIX = 'session-tile:'
/** Persisted placement — `dir` + strip slot (`before`) + dock `anchor` so a
* restart / profile swap re-adopts tiles in the same order, not all stacked
* right of workspace. */
type StoredTile = Pick<SessionTile, 'anchor' | 'before' | 'dir' | 'storedSessionId'>
type StoredTile = Pick<
SessionTile,
'anchor' | 'before' | 'dir' | 'storedSessionId' | 'workspaceMode' | 'workspaceOwnerKey'
>
const toStored = (t: SessionTile): StoredTile => ({
anchor: t.anchor,
before: t.before,
dir: t.dir,
storedSessionId: t.storedSessionId
storedSessionId: t.storedSessionId,
...(t.workspaceMode ? { workspaceMode: t.workspaceMode } : {}),
...(t.workspaceOwnerKey ? { workspaceOwnerKey: t.workspaceOwnerKey } : {})
})
function parseTileList(value: unknown): StoredTile[] {
@@ -570,7 +585,12 @@ function parseTileList(value: unknown): StoredTile[] {
anchor: typeof raw.anchor === 'string' ? raw.anchor : undefined,
before: typeof raw.before === 'string' || raw.before === null ? raw.before : undefined,
dir: raw.dir,
storedSessionId: raw.storedSessionId
storedSessionId: raw.storedSessionId,
workspaceMode: raw.workspaceMode === 'bots' ? 'bots' : 'sessions',
workspaceOwnerKey:
raw.workspaceMode === 'bots' && typeof raw.workspaceOwnerKey === 'string'
? raw.workspaceOwnerKey
: undefined
}
})
: []
@@ -653,6 +673,25 @@ export function patchSessionTile(storedSessionId: string, patch: Partial<Session
saveTiles($sessionTiles.get().map(t => (t.storedSessionId === storedSessionId ? { ...t, ...patch } : t)))
}
export function setSessionTileWorkspaceScope(
storedSessionId: string,
scope: SessionTileWorkspaceScope
): boolean {
const tile = $sessionTiles.get().find(candidate => candidate.storedSessionId === storedSessionId)
const workspaceOwnerKey = scope.workspaceMode === 'bots' ? scope.workspaceOwnerKey : undefined
if (
!tile ||
((tile.workspaceMode ?? 'sessions') === scope.workspaceMode && tile.workspaceOwnerKey === workspaceOwnerKey)
) {
return false
}
patchSessionTile(storedSessionId, { workspaceMode: scope.workspaceMode, workspaceOwnerKey })
return true
}
/** Drop live runtime bindings so every tile re-resumes — used on gateway
* reconnect, where a respawned backend re-mints (recycles) runtime ids.
* Also invalidates the wiring cache's stored→runtime map: clearing only the
@@ -795,7 +834,8 @@ export function openSessionTile(
storedSessionId: string,
dir: TileDock = 'right',
anchor?: string,
before?: null | string
before?: null | string,
workspaceScope: SessionTileWorkspaceScope = { workspaceMode: 'sessions' }
) {
const tiles = $sessionTiles.get()
@@ -813,14 +853,29 @@ export function openSessionTile(
const dock = anchor ?? focusedSessionTabAnchor() ?? undefined
const workspaceOwnerKey =
workspaceScope.workspaceMode === 'bots' ? workspaceScope.workspaceOwnerKey : undefined
if (!tiles.some(t => t.storedSessionId === storedSessionId)) {
saveTiles([...tiles, { anchor: dock, before, dir, storedSessionId }])
saveTiles([
...tiles,
{
anchor: dock,
before,
dir,
storedSessionId,
workspaceMode: workspaceScope.workspaceMode,
workspaceOwnerKey
}
])
// Adoption is async via the registry — order sync runs after the move path
// below; a brand-new tile's strip slot is already in `before`.
return
}
setSessionTileWorkspaceScope(storedSessionId, workspaceScope)
// Already open: relocate the existing pane to the drop target (pane-mirror
// only docks on first adoption, so a re-drag must move the tree pane itself).
const tree = $layoutTree.get()
@@ -940,7 +995,10 @@ export function blankDraftTile(
* False when there's no such tab, so the caller can fall back. The spent draft
* is DISCARDED rather than closed: it never held a conversation, so ⌘⇧T
* resurrecting it would just restore an empty tab. */
export function reuseBlankDraftTile(storedSessionId: string): boolean {
export function reuseBlankDraftTile(
storedSessionId: string,
workspaceScope: SessionTileWorkspaceScope = { workspaceMode: 'sessions' }
): boolean {
const tile = blankDraftTile($sessionTiles.get(), $sessionStates.get())
if (!tile || tile.storedSessionId === storedSessionId) {
@@ -948,7 +1006,7 @@ export function reuseBlankDraftTile(storedSessionId: string): boolean {
}
discardSessionTile(tile.storedSessionId)
openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before)
openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before, workspaceScope)
revealTreePane(`${TILE_PANE_PREFIX}${storedSessionId}`)
return true
@@ -964,7 +1022,7 @@ export function closeSessionTile(storedSessionId: string) {
const tile = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId)
if (tile) {
closedStack().push({ anchor: tile.anchor, before: tile.before, dir: tile.dir, storedSessionId })
closedStack().push(toStored(tile))
}
saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId))
@@ -1011,7 +1069,10 @@ export function reopenLastClosedTile(): void {
}
if (!$sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) {
openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before)
openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before, {
workspaceMode: tile.workspaceMode ?? 'sessions',
workspaceOwnerKey: tile.workspaceOwnerKey
})
focusOpenSession(storedSessionId)
return