feat(desktop): define workspace-scoped pane ownership
This commit is contained in:
committed by
Teknium
parent
613244cbb1
commit
b8b6f43280
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user